Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc845d7f5 | ||
|
|
a03458f9bc | ||
|
|
8013781e09 | ||
|
|
4d0bbd1d8e | ||
|
|
43d3ff7dea | ||
|
|
dcbf9a6c7e | ||
|
|
57ee555704 | ||
|
|
dee5819743 | ||
|
|
d86ed60b51 | ||
|
|
3e0b6b3653 | ||
|
|
29f62b639c | ||
|
|
da01c607a6 | ||
|
|
26d1d6d64f | ||
|
|
397be6236d | ||
|
|
b84bd8d555 | ||
|
|
0292404756 | ||
|
|
97842276c8 |
@@ -4,7 +4,9 @@ docs/apk/*.apk
|
|||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
.venv*/
|
||||||
venv/
|
venv/
|
||||||
|
|
||||||
# Локальный конфиг устройства (содержит lanip_key)
|
# Локальный конфиг устройства (содержит lanip_key)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ else()
|
|||||||
|
|
||||||
option(FGL_BUILD_TESTS "Build tests" ON)
|
option(FGL_BUILD_TESTS "Build tests" ON)
|
||||||
option(FGL_BUILD_EXAMPLES "Build examples (fglctl)" ON)
|
option(FGL_BUILD_EXAMPLES "Build examples (fglctl)" ON)
|
||||||
|
option(FGL_BUILD_SHARED "Build shared library (pyfglair/cffi)" ON)
|
||||||
option(FGL_BUNDLED_MBEDTLS "Fetch and build Mbed TLS instead of system" OFF)
|
option(FGL_BUNDLED_MBEDTLS "Fetch and build Mbed TLS instead of system" OFF)
|
||||||
|
|
||||||
# ---- mbedtls ----
|
# ---- mbedtls ----
|
||||||
@@ -71,14 +72,16 @@ else()
|
|||||||
set(USE_SHARED_MBEDTLS_LIBRARY OFF CACHE BOOL "" FORCE)
|
set(USE_SHARED_MBEDTLS_LIBRARY OFF CACHE BOOL "" FORCE)
|
||||||
set(USE_STATIC_MBEDTLS_LIBRARY ON CACHE BOOL "" FORCE)
|
set(USE_STATIC_MBEDTLS_LIBRARY ON CACHE BOOL "" FORCE)
|
||||||
FetchContent_MakeAvailable(mbedtls)
|
FetchContent_MakeAvailable(mbedtls)
|
||||||
set(FGL_MBEDTLS_TARGET mbedcrypto STATIC)
|
set(FGL_MBEDTLS_TARGET mbedcrypto)
|
||||||
set(FGL_MBEDTLS_INCLUDE_DIR "")
|
set(FGL_MBEDTLS_INCLUDE_DIR "")
|
||||||
|
# Статический mbedcrypto нужен и для shared-библиотеки (pyfglair).
|
||||||
|
set_target_properties(mbedcrypto PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||||
else()
|
else()
|
||||||
set(FGL_MBEDTLS_TARGET "${MBEDTLS_CRYPTO_LIB}")
|
set(FGL_MBEDTLS_TARGET "${MBEDTLS_CRYPTO_LIB}")
|
||||||
set(FGL_MBEDTLS_INCLUDE_DIR "${MBEDTLS_INCLUDE_DIR}")
|
set(FGL_MBEDTLS_INCLUDE_DIR "${MBEDTLS_INCLUDE_DIR}")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
add_library(fgl-aircon STATIC
|
set(FGL_SOURCES
|
||||||
src/ayla/crypto.cpp
|
src/ayla/crypto.cpp
|
||||||
src/ayla/envelope.cpp
|
src/ayla/envelope.cpp
|
||||||
src/ayla/httpc.cpp
|
src/ayla/httpc.cpp
|
||||||
@@ -90,20 +93,39 @@ else()
|
|||||||
src/aircon/tables.cpp
|
src/aircon/tables.cpp
|
||||||
src/ayla/platform/posix/platform.cpp
|
src/ayla/platform/posix/platform.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(fgl-aircon
|
|
||||||
PUBLIC
|
function(fgl_configure_target tgt)
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/include"
|
target_include_directories(${tgt}
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}"
|
PUBLIC
|
||||||
"${FGL_MBEDTLS_INCLUDE_DIR}"
|
"${CMAKE_CURRENT_SOURCE_DIR}/include"
|
||||||
PRIVATE
|
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
"${FGL_MBEDTLS_INCLUDE_DIR}"
|
||||||
)
|
PRIVATE
|
||||||
target_link_libraries(fgl-aircon PUBLIC ${FGL_MBEDTLS_TARGET})
|
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||||
target_compile_features(fgl-aircon PUBLIC cxx_std_20)
|
)
|
||||||
target_compile_options(fgl-aircon PRIVATE
|
target_link_libraries(${tgt} PUBLIC ${FGL_MBEDTLS_TARGET})
|
||||||
-Wall -Wextra -Werror
|
target_compile_features(${tgt} PUBLIC cxx_std_20)
|
||||||
-fno-exceptions -fno-rtti
|
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}
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
if(FGL_BUILD_TESTS)
|
if(FGL_BUILD_TESTS)
|
||||||
enable_testing()
|
enable_testing()
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
include CMakeLists.txt 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
|
||||||
@@ -13,9 +13,10 @@ See [`docs/PROTOCOL.md`](docs/PROTOCOL.md) for the protocol reference and
|
|||||||
include/fgl-aircon/ public API (types, templates, session, C API for cffi)
|
include/fgl-aircon/ public API (types, templates, session, C API for cffi)
|
||||||
src/ayla/ Ayla LAN protocol: crypto, envelope, HTTP, session loop
|
src/ayla/ Ayla LAN protocol: crypto, envelope, HTTP, session loop
|
||||||
src/aircon/ property templates A/B/F, conversions, API implementation
|
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)
|
components/ ESPHome external component (planned, docs/PLAN_ESPHOME.md)
|
||||||
custom_components/ Home Assistant custom component (planned, docs/PLAN_HOME_ASSISTANT.md)
|
custom_components/ Home Assistant custom component (docs/PLAN_HOME_ASSISTANT.md)
|
||||||
tests/ ayla (protocol), aircon (conversions), tools
|
tests/ ayla (protocol), aircon (conversions), pyfglair, tools
|
||||||
examples/cli/ fglctl — sample CLI
|
examples/cli/ fglctl — sample CLI
|
||||||
tools/ fglair-discover, protocol probes
|
tools/ fglair-discover, protocol probes
|
||||||
```
|
```
|
||||||
@@ -78,6 +79,29 @@ session scenarios against a Python mock of the module).
|
|||||||
`tests/aircon` covers template tables and conversions.
|
`tests/aircon` covers template tables and conversions.
|
||||||
No hardware needed.
|
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`)
|
## Getting the key (`lanip_key`)
|
||||||
|
|
||||||
The key is static (baked into the AC module; only served by the Ayla cloud):
|
The key is static (baked into the AC module; only served by the Ayla cloud):
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# 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).
|
||||||
|
До публикации на PyPI установите wheel вручную в python-окружение HA:
|
||||||
|
`pip install pyfglair-*.whl` (см. [`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. **Проверка шаблона с превью**: форма-предпросмотр рассчитанных
|
||||||
|
значений (режимы, диапазон температур, пример конверсии) и, при
|
||||||
|
необходимости, ручные коэффициенты/диапазон уставки. Сверьте с
|
||||||
|
приложением FGLair и подтвердите.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Должно быть видно: таблица превью — режимы, диапазон 16–30 °C, шаг,
|
||||||
|
текущая температура, capabilities, кнопки Submit.*
|
||||||
|
|
||||||
|
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`;
|
||||||
|
режимы, которыми прибор не управляет, не создаются.
|
||||||
|
|
||||||
|
## Диагностика и известные ситуации
|
||||||
|
|
||||||
|
* **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
|
||||||
|
```
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""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, DOMAIN
|
||||||
|
from .coordinator import FglairCoordinator, FglairRuntime, key_issue_id
|
||||||
|
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
|
||||||
|
|
||||||
|
clients[entry.entry_id] = client
|
||||||
|
entry.runtime_data = FglairRuntime(client, coordinator, conversions)
|
||||||
|
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, "")))
|
||||||
|
)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""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)
|
||||||
|
After Width: | Height: | Size: 607 B |
|
After Width: | Height: | Size: 832 B |
@@ -0,0 +1,262 @@
|
|||||||
|
"""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 (
|
||||||
|
CAP_FAN,
|
||||||
|
CAP_HVAC,
|
||||||
|
CAP_SWING_HORIZONTAL,
|
||||||
|
CAP_SWING_VERTICAL,
|
||||||
|
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_CAP,
|
||||||
|
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)])
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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, bit in CAP_HVAC.items():
|
||||||
|
if self.supports_capability(bit):
|
||||||
|
modes.append(mode)
|
||||||
|
return modes
|
||||||
|
|
||||||
|
def _fan_modes(self) -> Optional[list[str]]:
|
||||||
|
if not self._in_template(Prop.FAN_SPEED):
|
||||||
|
return None
|
||||||
|
return [
|
||||||
|
mode
|
||||||
|
for mode, bit in CAP_FAN.items()
|
||||||
|
if self.supports_capability(bit)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _preset_modes(self) -> Optional[list[str]]:
|
||||||
|
presets = [
|
||||||
|
preset
|
||||||
|
for preset, bit in PRESET_CAP.items()
|
||||||
|
if self._in_template(PRESET_PROP[preset])
|
||||||
|
and self.supports_capability(bit)
|
||||||
|
]
|
||||||
|
return presets or None
|
||||||
|
|
||||||
|
def _vertical_swing_supported(self) -> bool:
|
||||||
|
return self._in_template(Prop.AF_VERTICAL_SWING) and (
|
||||||
|
self.supports_capability(CAP_SWING_VERTICAL)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _horizontal_swing_supported(self) -> bool:
|
||||||
|
return self._in_template(Prop.AF_HORIZONTAL_SWING) and (
|
||||||
|
self.supports_capability(CAP_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()
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
"""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 (
|
||||||
|
CAP_FAN,
|
||||||
|
CAP_HVAC,
|
||||||
|
CAP_SWING_HORIZONTAL,
|
||||||
|
CAP_SWING_VERTICAL,
|
||||||
|
CONF_CONFIG_JSON,
|
||||||
|
CONF_DEVICE_PORT,
|
||||||
|
CONF_DSN,
|
||||||
|
CONF_EMAIL,
|
||||||
|
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,
|
||||||
|
REGIONS,
|
||||||
|
TEMP_MAX_TENTHS,
|
||||||
|
TEMP_MIN_TENTHS,
|
||||||
|
TEMP_STEP_TENTHS,
|
||||||
|
TRIAL_ERRORS,
|
||||||
|
TRIAL_TIMEOUT,
|
||||||
|
)
|
||||||
|
from .overrides import ConversionSet, LinearOverride
|
||||||
|
from .trial import TrialResult, 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 _preview_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_TEMPLATE, default=template.name): vol.In(
|
||||||
|
["A", "B", "F"]
|
||||||
|
),
|
||||||
|
vol.Required(CONF_TEMP_MIN, default=temp_min / 10): vol.All(
|
||||||
|
vol.Coerce(float), vol.Range(min=-10.0, max=45.0)
|
||||||
|
),
|
||||||
|
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 _preview_text(
|
||||||
|
template: Template, properties: dict[Any, Any]
|
||||||
|
) -> str:
|
||||||
|
def raw(prop) -> int | None:
|
||||||
|
value = properties.get(prop)
|
||||||
|
return value.int_value if value is not None else None
|
||||||
|
|
||||||
|
caps = raw(Prop.DEVICE_CAPABILITIES)
|
||||||
|
|
||||||
|
def supported(bit: int) -> bool:
|
||||||
|
return caps is None or bool(caps & (1 << bit))
|
||||||
|
|
||||||
|
hvac = ["off"] + [
|
||||||
|
mode.value for mode, bit in CAP_HVAC.items() if supported(bit)
|
||||||
|
]
|
||||||
|
fan = [mode for mode, bit in CAP_FAN.items() if supported(bit)]
|
||||||
|
tmin, tmax = TEMP_MIN_TENTHS / 10, TEMP_MAX_TENTHS / 10
|
||||||
|
step = TEMP_STEP_TENTHS[template] / 10
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"- HVAC: {', '.join(hvac)}",
|
||||||
|
f"- Fan: {', '.join(fan) or '-'}",
|
||||||
|
f"- Setpoint: {tmin:.1f}-{tmax:.1f} °C, step {step:.1f} °C",
|
||||||
|
]
|
||||||
|
current = raw(Prop.DISPLAY_TEMPERATURE)
|
||||||
|
if current is not None:
|
||||||
|
lines.append(f"- Current temperature: {current / 10:.1f} °C")
|
||||||
|
swings = []
|
||||||
|
if supported(CAP_SWING_VERTICAL):
|
||||||
|
swings.append("vertical")
|
||||||
|
if supported(CAP_SWING_HORIZONTAL):
|
||||||
|
swings.append("horizontal")
|
||||||
|
if swings:
|
||||||
|
lines.append(f"- Swing: {', '.join(swings)}")
|
||||||
|
if caps is not None:
|
||||||
|
lines.append(f"- Capabilities: 0x{caps:08X}")
|
||||||
|
lines.append(f"- Template: {template.name}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
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]) -> TrialResult:
|
||||||
|
return trial_connect(
|
||||||
|
host=data[CONF_HOST],
|
||||||
|
dsn=data[CONF_DSN],
|
||||||
|
lanip_key=data[CONF_LANIP_KEY],
|
||||||
|
lanip_key_id=int(data[CONF_LANIP_KEY_ID]),
|
||||||
|
device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)),
|
||||||
|
template=Template[str(data.get(CONF_TEMPLATE, "A"))],
|
||||||
|
listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)),
|
||||||
|
timeout=TRIAL_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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._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_preview()
|
||||||
|
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_preview()
|
||||||
|
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_preview()
|
||||||
|
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)
|
||||||
|
return self._create_entry(data)
|
||||||
|
|
||||||
|
async def async_step_preview(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Шаг 3: превью шаблона и ручные конверсии/диапазон уставки."""
|
||||||
|
data = dict(self._pending_data)
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
template = Template[user_input[CONF_TEMPLATE]]
|
||||||
|
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]
|
||||||
|
):
|
||||||
|
# Достижимый диапазон пуст: конверсия выводит таблицу
|
||||||
|
# шаблона за пределы, которые не пересекаются с min/max.
|
||||||
|
errors["base"] = "invalid_range"
|
||||||
|
else:
|
||||||
|
data[CONF_TEMPLATE] = template.name
|
||||||
|
data[CONF_TEMP_STEP] = step
|
||||||
|
data[CONF_OVERRIDES] = {
|
||||||
|
Prop.ADJUST_TEMPERATURE.name.lower(): {
|
||||||
|
"num": num,
|
||||||
|
"den": den,
|
||||||
|
"offset": offset,
|
||||||
|
"min": temp_min,
|
||||||
|
"max": temp_max,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_LOGGER.debug(
|
||||||
|
"превью: template=%s range=%d..%d step=%d",
|
||||||
|
template.name, temp_min, temp_max, step,
|
||||||
|
)
|
||||||
|
return self._create_entry(data)
|
||||||
|
selected = str(data.get(CONF_TEMPLATE, Template.A.name))
|
||||||
|
if user_input and user_input.get(CONF_TEMPLATE) in Template.__members__:
|
||||||
|
selected = str(user_input[CONF_TEMPLATE])
|
||||||
|
if user_input is None:
|
||||||
|
user_input = {CONF_TEMPLATE: selected}
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="preview",
|
||||||
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
|
_preview_schema(data), user_input
|
||||||
|
),
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={
|
||||||
|
"preview": _preview_text(
|
||||||
|
Template[selected], self._trial_properties
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False)
|
||||||
|
if self._reconfigure_entry is not None:
|
||||||
|
self._abort_if_unique_id_mismatch()
|
||||||
|
else:
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
result: TrialResult = await self.hass.async_add_executor_job(
|
||||||
|
partial(_run_trial, data)
|
||||||
|
)
|
||||||
|
if result.ok:
|
||||||
|
self._pending_data = data
|
||||||
|
self._trial_properties = result.properties
|
||||||
|
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, "")),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Константы и словари значений интеграции 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_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,
|
||||||
|
}
|
||||||
|
PRESET_CAP = {
|
||||||
|
PRESET_ECO: CAP_ECONOMY,
|
||||||
|
PRESET_BOOST: CAP_POWERFUL,
|
||||||
|
}
|
||||||
|
|
||||||
|
SWING_MODES = ["off", "on"]
|
||||||
|
|
||||||
|
# switch-свойства: prop -> (translation_key, capability bit | None)
|
||||||
|
SWITCH_PROPS: dict[Prop, tuple[str, int | None]] = {
|
||||||
|
Prop.ECONOMY_MODE: ("economy_mode", CAP_ECONOMY),
|
||||||
|
Prop.POWERFUL_MODE: ("powerful_mode", CAP_POWERFUL),
|
||||||
|
Prop.COIL_DRY_MODE: ("coil_dry_mode", CAP_COIL_DRY),
|
||||||
|
Prop.MIN_HEAT: ("min_heat", CAP_MIN_HEAT),
|
||||||
|
Prop.OUTDOOR_LOW_NOISE: ("outdoor_low_noise", CAP_OUTDOOR_LOW_NOISE),
|
||||||
|
Prop.HUMAN_DET_AUTO_SAVE: ("human_det_auto_save", None),
|
||||||
|
Prop.WIFI_LED_ENABLE: ("wifi_led_enable", None),
|
||||||
|
Prop.INDOOR_FAN_CONTROL: ("indoor_fan_control", CAP_INDOOR_FAN_CONTROL),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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)
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""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 template_info
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
AVAILABLE_STATES,
|
||||||
|
CONF_DSN,
|
||||||
|
CONF_HOST,
|
||||||
|
CONF_NAME,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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.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) -> None:
|
||||||
|
"""Гарантирует первичный GET-батч (если уже online)."""
|
||||||
|
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)
|
||||||
|
self._synced = True
|
||||||
|
except Exception: # pragma: no cover - защитный путь
|
||||||
|
_LOGGER.exception("первичная синхронизация свойств не удалась")
|
||||||
|
finally:
|
||||||
|
self._sync_task = None
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Диагностика 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
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Базовая сущность 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
|
||||||
|
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],
|
||||||
|
) -> 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._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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def capabilities(self) -> Optional[int]:
|
||||||
|
return self.raw_value(Prop.DEVICE_CAPABILITIES)
|
||||||
|
|
||||||
|
def capability(self, bit: int) -> Optional[bool]:
|
||||||
|
caps = self.capabilities()
|
||||||
|
if not caps:
|
||||||
|
# 0/None: возможностей ещё не сообщили — не фильтруем.
|
||||||
|
return None
|
||||||
|
return bool(caps & (1 << bit))
|
||||||
|
|
||||||
|
def supports_capability(self, bit: Optional[int]) -> bool:
|
||||||
|
"""None — считать поддержанным (capabilities ещё не пришли)."""
|
||||||
|
if bit is None:
|
||||||
|
return True
|
||||||
|
cap = self.capability(bit)
|
||||||
|
return True if cap is None else cap
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Фоновый клиент 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()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"domain": "fglair",
|
||||||
|
"name": "FGLair (Fujitsu General)",
|
||||||
|
"codeowners": [],
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Ручные конверсии/диапазоны свойств (план §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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""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()
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,74 @@
|
|||||||
|
"""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 (
|
||||||
|
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 (
|
||||||
|
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)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""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:
|
||||||
|
entities.append(FglairRoomTemperature(coordinator))
|
||||||
|
if Prop.ERROR_CODE in props:
|
||||||
|
entities.append(FglairErrorCode(coordinator))
|
||||||
|
async_add_entities(entities)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Template preview",
|
||||||
|
"description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.",
|
||||||
|
"data": {
|
||||||
|
"template": "Template",
|
||||||
|
"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; raw is the protocol value (setpoint: 0.1 °C)",
|
||||||
|
"temp_offset": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""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, capability) in SWITCH_PROPS.items():
|
||||||
|
if prop_info(template, prop) is None:
|
||||||
|
continue
|
||||||
|
if not _capability_supported(coordinator, capability):
|
||||||
|
continue
|
||||||
|
entities.append(FglairSwitch(coordinator, prop, key))
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
def _capability_supported(
|
||||||
|
coordinator: FglairCoordinator, bit: int | None
|
||||||
|
) -> bool:
|
||||||
|
if bit is None:
|
||||||
|
return True
|
||||||
|
caps = coordinator.data.values.get(Prop.DEVICE_CAPABILITIES)
|
||||||
|
if caps is None or not caps.int_value:
|
||||||
|
return True
|
||||||
|
return bool(caps.int_value & (1 << bit))
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Template preview",
|
||||||
|
"description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.",
|
||||||
|
"data": {
|
||||||
|
"template": "Template",
|
||||||
|
"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; raw is the protocol value (setpoint: 0.1 °C)",
|
||||||
|
"temp_offset": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Превью шаблона",
|
||||||
|
"description": "Рассчитанные значения (сверьте с приложением FGLair):\n\n{preview}\n\nДиапазон уставки и конверсию можно поправить ниже.",
|
||||||
|
"data": {
|
||||||
|
"template": "Шаблон",
|
||||||
|
"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; raw — протокольное значение (уставка — 0,1 °C)",
|
||||||
|
"temp_offset": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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-ключ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Пробное подключение при настройке: старт сессии, ONLINE ≤ timeout, чтение
|
||||||
|
базовых свойств. Выполняется в executor (блокирующие вызовы ядра).
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
BASE_PROPS: tuple[Prop, ...] = (
|
||||||
|
Prop.OPERATION_MODE,
|
||||||
|
Prop.FAN_SPEED,
|
||||||
|
Prop.DISPLAY_TEMPERATURE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrialResult:
|
||||||
|
ok: bool
|
||||||
|
reason: str = ""
|
||||||
|
state: State = State.IDLE
|
||||||
|
error: Error = Error.NONE
|
||||||
|
properties: dict[Prop, Value] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
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 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] = BASE_PROPS,
|
||||||
|
) -> 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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
if wanted:
|
||||||
|
session.batch_begin()
|
||||||
|
for prop in wanted:
|
||||||
|
session.get_prop(prop)
|
||||||
|
session.batch_commit()
|
||||||
|
prop_deadline = min(deadline, time.monotonic() + 2.0)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
return TrialResult(ok=True, state=State.ONLINE, properties=snapshot)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
@@ -31,14 +31,17 @@ Fallback, если сборка wheel станет блокером: сборк
|
|||||||
* `pyfglair/_corebuild.py` — cmake-сборка при упаковке wheel;
|
* `pyfglair/_corebuild.py` — cmake-сборка при упаковке wheel;
|
||||||
* `pyfglair/_cffi.py` — cffi-декларации поверх `include/fgl-aircon/c_api.h`;
|
* `pyfglair/_cffi.py` — cffi-декларации поверх `include/fgl-aircon/c_api.h`;
|
||||||
* `pyfglair/session.py` — обёртка `Session(cfg)`; колбэки ядра → asyncio
|
* `pyfglair/session.py` — обёртка `Session(cfg)`; колбэки ядра → asyncio
|
||||||
через `loop.call_soon_threadsafe`;
|
через `loop.call_soon_threadsafe`. Транспорт событий — C-очередь
|
||||||
|
(`fgl_session_wait_events`) и выделенный python-поток: прямые cffi-колбэки
|
||||||
|
из потоков ядра небезопасны (у session-потока стек 8 КБ — CPython
|
||||||
|
падает по stack overflow);
|
||||||
* `pyfglair/templates.py` — интроспекция шаблонов через cffi-вызовы
|
* `pyfglair/templates.py` — интроспекция шаблонов через cffi-вызовы
|
||||||
`fgl_template_info_get` / `fgl_convert_to_display` (для превью в
|
`fgl_template_info_get` / `fgl_convert_to_display` (для превью в
|
||||||
config flow; единый источник данных — C-таблицы, дублей нет);
|
config flow; единый источник данных — C-таблицы, дублей нет);
|
||||||
* `pyfglair/provision.py` — облачный discovery (перенос логики
|
* `pyfglair/provision.py` — облачный discovery (перенос логики
|
||||||
`docs/legacy/aircon/discovery.py` на современный aiohttp): e-mail/
|
`docs/legacy/aircon/discovery.py` на современный aiohttp): e-mail/
|
||||||
пароль/регион → dsn, host/ip, oem_model, lanip_key, lanip_key_id;
|
пароль/регион → dsn, host/ip, oem_model, lanip_key, lanip_key_id;
|
||||||
* CLI: `python -m pyfglair discover` / `monitor` / `--output esphome-secrets`
|
* CLI: `python -m pyfglair discover` / `monitor` / `--format esphome-secrets`
|
||||||
(печать блока для secrets.yaml ESPHome).
|
(печать блока для secrets.yaml ESPHome).
|
||||||
2. **`custom_components/fglair/`**:
|
2. **`custom_components/fglair/`**:
|
||||||
```
|
```
|
||||||
@@ -47,7 +50,7 @@ Fallback, если сборка wheel станет блокером: сборк
|
|||||||
fglair_client.py # фоновый поток с FglSession
|
fglair_client.py # фоновый поток с FglSession
|
||||||
coordinator.py # push-driven coordinator
|
coordinator.py # push-driven coordinator
|
||||||
climate.py sensor.py switch.py select.py binary_sensor.py
|
climate.py sensor.py switch.py select.py binary_sensor.py
|
||||||
diagnostics.py # lanip_key/key_id/dsn видны для копирования в ESPHome
|
diagnostics.py # dsn/key_id/host + маска lanip_key; полный ключ — options flow
|
||||||
translations/{en,ru}.json
|
translations/{en,ru}.json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -84,9 +87,11 @@ Repair (теоретический): `key_error` → «Ключ устройст
|
|||||||
перепровижинируйте» (повторный облако-вход по требованию). Облако в рантайме
|
перепровижинируйте» (повторный облако-вход по требованию). Облако в рантайме
|
||||||
не используется, ключ статичен.
|
не используется, ключ статичен.
|
||||||
|
|
||||||
**Диагностика**: device-страница показывает `dsn`, `lanip_key`, `lanip_key_id`,
|
**Диагностика**: device-страница показывает `dsn`, `lanip_key_id`, `host` и
|
||||||
`host` — источник для копирования в secrets ESPHome. В redacted-дампе ключ
|
маску `lanip_key` (дамп прикладывают в issues — секретов в нём нет). Полный
|
||||||
маскируется.
|
ключ для secrets ESPHome берётся явным действием: reconfigure («Настроить
|
||||||
|
заново») на карточке интеграции или CLI `python -m pyfglair discover` на
|
||||||
|
хосте HA.
|
||||||
|
|
||||||
## 4. Сущности
|
## 4. Сущности
|
||||||
|
|
||||||
@@ -96,6 +101,9 @@ switch: economy/powerful/coil_dry/min_heat/outdoor_low_noise/
|
|||||||
human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки,
|
human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки,
|
||||||
demand_control; binary_sensor: connectivity). capabilities фильтруют
|
demand_control; binary_sensor: connectivity). capabilities фильтруют
|
||||||
режимы/пресеты. Записи — один `batch_commit()` на действие пользователя.
|
режимы/пресеты. Записи — один `batch_commit()` на действие пользователя.
|
||||||
|
Demand_control: семантика значений в PROTOCOL §8.3 не зафиксирована
|
||||||
|
(§10) — сущность не создаётся до уточнения; в H3 вместо неё
|
||||||
|
диагностический sensor состояния связи.
|
||||||
|
|
||||||
## 5. Runtime
|
## 5. Runtime
|
||||||
|
|
||||||
@@ -110,8 +118,9 @@ asyncio → push-coordinator. Состояния: `online` → available;
|
|||||||
Структура (`screenshots/step-N.png` — заглушки-плейсхолдеры, владелец заменит
|
Структура (`screenshots/step-N.png` — заглушки-плейсхолдеры, владелец заменит
|
||||||
реальными скриншотами; рядом с каждой — описание что должно быть видно):
|
реальными скриншотами; рядом с каждой — описание что должно быть видно):
|
||||||
|
|
||||||
1. **Установка через HACS**:
|
1. **Установка через HACS** (нужно публичное GitHub-зеркало — HACS не
|
||||||
* HACS → ⋮ → Custom repositories → URL репозитория, категория
|
поддерживает GitLab/Gitea; либо ручное копирование `custom_components/`):
|
||||||
|
* HACS → ⋮ → Custom repositories → URL GitHub-зеркала, категория
|
||||||
Integration → Add. Скриншот: диалог добавления custom repository с
|
Integration → Add. Скриншот: диалог добавления custom repository с
|
||||||
заполненным URL и выбранной категорией Integration.
|
заполненным URL и выбранной категорией Integration.
|
||||||
* FGLair → Download → перезапуск HA. Скриншот: страница загрузки
|
* FGLair → Download → перезапуск HA. Скриншот: страница загрузки
|
||||||
@@ -129,7 +138,7 @@ asyncio → push-coordinator. Состояния: `online` → available;
|
|||||||
6. **Готово**: карточка устройства со списком сущностей. Скриншот: страница
|
6. **Готово**: карточка устройства со списком сущностей. Скриншот: страница
|
||||||
устройства с созданными climate/sensor/switch сущностями.
|
устройства с созданными climate/sensor/switch сущностями.
|
||||||
7. **Где взять ключ для ESPHome**: диагностика устройства. Скриншот: страница
|
7. **Где взять ключ для ESPHome**: диагностика устройства. Скриншот: страница
|
||||||
Diagnostics с полями dsn/lanip_key/lanip_key_id.
|
Diagnostics с полями dsn/lanip_key_id/host и маской lanip_key.
|
||||||
8. Troubleshooting: 503 (оба слота заняты — телефон+ESP?), key_error,
|
8. Troubleshooting: 503 (оба слота заняты — телефон+ESP?), key_error,
|
||||||
недоступность.
|
недоступность.
|
||||||
|
|
||||||
@@ -144,15 +153,18 @@ Security → Long-lived access tokens) и REST API: вызов сервисов
|
|||||||
(OAuth-флоу) не нужна — пользователь просто создаёт токен и передаёт
|
(OAuth-флоу) не нужна — пользователь просто создаёт токен и передаёт
|
||||||
скрипту (`--ha-url`, `--ha-token`).
|
скрипту (`--ha-url`, `--ha-token`).
|
||||||
Режимы: quick (матрица burst/не-burst, обе стороны, с возвратом) и `--long`
|
Режимы: quick (матрица burst/не-burst, обе стороны, с возвратом) и `--long`
|
||||||
(24 ч, ежечасное изменение с проверкой и возвратом, CSV-отчёт). Скрипт НЕ
|
(24 ч, ежечасное изменение с проверкой и возвратом, CSV-отчёт дописывается
|
||||||
открывает собственную сессию к кондиционеру (оба слота заняты HA+ESP).
|
по ходу). Скрипт НЕ открывает собственную сессию к кондиционеру (оба слота
|
||||||
|
заняты HA+ESP). ESPHome-канал (опционально, `--esphome-host`): через native
|
||||||
|
API отправляются шаги «режим» и «уставка», проверка — по состоянию в HA
|
||||||
|
(сквозная цепочка ESPHome → модуль → HA); fan/swing — только со стороны HA.
|
||||||
|
|
||||||
## 8. Этапы
|
## 8. Этапы
|
||||||
|
|
||||||
| # | Содержимое |
|
| # | Содержимое |
|
||||||
|---|-----------|
|
|---|-----------|
|
||||||
| H1 | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
|
| H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
|
||||||
| H2 | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
|
| H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
|
||||||
| H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
|
| H3 ✅ | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
|
||||||
| H4 | repair, диагностика (ключ для ESPHome), translations |
|
| H4 ✅ | repair, диагностика (ключ для ESPHome), translations |
|
||||||
| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз |
|
| H5 ✅ | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), hacs.json; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64) — релизное действие, чек-лист `docs/RELEASE_HA.md` |
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ docs/ # документация (ниже) + матер
|
|||||||
| `../README.md` | README библиотеки: сборка POSIX/ESP-IDF, тесты, fglctl, `tools/fglair-discover`. |
|
| `../README.md` | README библиотеки: сборка POSIX/ESP-IDF, тесты, fglctl, `tools/fglair-discover`. |
|
||||||
| `../tools/fglair-discover` | Облачный provisioning (ключ устройства, `--format esphome-secrets`). |
|
| `../tools/fglair-discover` | Облачный provisioning (ключ устройства, `--format esphome-secrets`). |
|
||||||
| `../examples/cli/fglctl.cpp` | Пример CLI на публичном API. |
|
| `../examples/cli/fglctl.cpp` | Пример CLI на публичном API. |
|
||||||
|
| `reports/M4_SOAK.md` | Отчёт приёмочного soak-прогона на приборе (60 мин, 0 рассинхронов). |
|
||||||
|
|
||||||
## Краткая выжимка протокола
|
## Краткая выжимка протокола
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Чек-лист релиза HA-интеграции (H5)
|
||||||
|
|
||||||
|
## 1. Публикация pyfglair (PyPI)
|
||||||
|
|
||||||
|
Манифест компонента требует `pyfglair>=1.0.0`; штатный installer HA
|
||||||
|
резолвит требования только через PyPI. PyPI (warehouse) принимает только
|
||||||
|
`manylinux*`/`musllinux*` платформенные теги — локальный
|
||||||
|
`py3-none-linux_x86_64` будет отклонён (`HTTP 400 unsupported platform
|
||||||
|
tag`), поэтому wheel нужно «починить» auditwheel'ом в контейнере со старой
|
||||||
|
glibc (manylinux):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install build auditwheel twine
|
||||||
|
|
||||||
|
# для каждой архитектуры (linux x86_64/aarch64) в manylinux-контейнере
|
||||||
|
# (например, quay.io/pypa/manylinux2014_<arch>):
|
||||||
|
python -m build --wheel # .so без внешнего mbedcrypto
|
||||||
|
auditwheel repair dist/pyfglair-*.whl \
|
||||||
|
--plat manylinux2014_x86_64 -w dist/manylinux/ # или manylinux_2_28
|
||||||
|
rm -f dist/pyfglair-*-linux_*.whl # linux-тег PyPI отклонит
|
||||||
|
python -m build --sdist # sdist с C++-исходниками (MANIFEST.in)
|
||||||
|
twine upload dist/manylinux/* dist/*.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверки перед публикацией:
|
||||||
|
|
||||||
|
* `scripts/py-ci.sh` — pyfglair + acceptance self-test + components;
|
||||||
|
* `scripts/ci.sh` — ядро (gcc/clang, ASan/UBSan) 11/11;
|
||||||
|
* `readelf -d build-pyfglair/libfgl-aircon.so*` (или распакованный wheel:
|
||||||
|
`pyfglair/libfgl-aircon.so*`) — нет внешней `libmbedcrypto` (встроен);
|
||||||
|
* `auditwheel show` для repaired-wheel — тег manylinux и только
|
||||||
|
базовые системные библиотеки;
|
||||||
|
* установка wheel в чистый venv вне репозитория:
|
||||||
|
`python -c "import pyfglair.session"`;
|
||||||
|
* aarch64: сборка в соответствующем manylinux-контейнере (QEMU или
|
||||||
|
нативная), иначе пользователи ARM не смогут поставить `pyfglair`.
|
||||||
|
|
||||||
|
Альтернатива — cibuildwheel (`pip install cibuildwheel`) с `auditwheel
|
||||||
|
repair` внутри, но текущий `bdist_wheel`-хук отдаёт тег
|
||||||
|
`py3-none-<plat>`, поэтому проверяйте итоговые теги вручную.
|
||||||
|
|
||||||
|
## 2. GitHub-зеркало и HACS
|
||||||
|
|
||||||
|
HACS устанавливает интеграции только с публичных **GitHub**-репозиториев
|
||||||
|
(GitLab/Gitea не поддерживаются). Шаги:
|
||||||
|
|
||||||
|
* создать GitHub-зеркало (push mirror) и убедиться, что оно содержит
|
||||||
|
`custom_components/fglair/`, `hacs.json`, тег релиза;
|
||||||
|
* заполнить `codeowners` в `custom_components/fglair/manifest.json`
|
||||||
|
(GitHub-хендлы; сейчас пустой список);
|
||||||
|
* проверить `documentation`/`issue_tracker` в манифесте;
|
||||||
|
* обновить `version` манифеста под номер тега (сейчас `0.1.0`, pyfglair —
|
||||||
|
`1.0.0`; синхронизируйте по вкусу — компонент и пакет версионируются
|
||||||
|
отдельно);
|
||||||
|
* заменить заглушки `custom_components/fglair/screenshots/step-N.png`
|
||||||
|
реальными скриншотами (описания «что должно быть видно» — в README);
|
||||||
|
* заменить заглушки `custom_components/fglair/brand/{icon,logo}.png`
|
||||||
|
фирменными ассетами;
|
||||||
|
* создать git-тег (semver) в GitHub-зеркале — HACS покажет версию.
|
||||||
|
|
||||||
|
## 3. Приёмка на живом стенде
|
||||||
|
|
||||||
|
* `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 без потери конверсий.
|
||||||
|
|
||||||
|
## 4. Ограничения текущего стенда
|
||||||
|
|
||||||
|
* Реального железа/облака в CI нет: протокол проверен mock-модулем
|
||||||
|
(`tests/ayla/mock_ac.py`), облако — мок-сервером; приёмочный скрипт имеет
|
||||||
|
self-test на моке HA REST.
|
||||||
|
* Суточный soak, re-key и облачный вход — только на приборе/аккаунте.
|
||||||
|
* ESPHome-канал приёмки требует установленного `aioesphomeapi` и
|
||||||
|
работающего ESPHome-компонента (PLAN_ESPHOME); без них скрипт работает
|
||||||
|
только со стороны HA.
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|---------|----------|
|
|---------|----------|
|
||||||
| Смены состояний | registering → online → idle (stop) |
|
| Смены состояний | registering → online → idle (stop) |
|
||||||
| recovering / offline / KEY_ERROR | **0** |
|
| recovering / offline / KEY_ERROR | **0** |
|
||||||
| Push'ей свойств | 33 (все свойства шаблона A, вкл. строковые) |
|
| Свойств получено | 33 (ответы на стартовый batch GET, все свойства шаблона A, вкл. строковые) |
|
||||||
| Push'ей со статусом ≠ 200 | **0** |
|
| Push'ей со статусом ≠ 200 | **0** |
|
||||||
| `pushes_ok` / `pushes_bad` | 33 / **0** |
|
| `pushes_ok` / `pushes_bad` | 33 / **0** |
|
||||||
| Опросов `commands.json` модулем | 273 (~каждые 13 с) |
|
| Опросов `commands.json` модулем | 273 (~каждые 13 с) |
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
// "lanip_key_id": N, "model": "AP-WC1E", ...}
|
// "lanip_key_id": N, "model": "AP-WC1E", ...}
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
@@ -70,7 +71,7 @@ bool load_config(const char* path, fgl::aircon::Config* cfg,
|
|||||||
}
|
}
|
||||||
static char buf[4096];
|
static char buf[4096];
|
||||||
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
|
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
|
||||||
bool truncated = !feof(f);
|
bool truncated = n == sizeof(buf) - 1 && !feof(f);
|
||||||
fclose(f);
|
fclose(f);
|
||||||
buf[n] = '\0';
|
buf[n] = '\0';
|
||||||
if (truncated) {
|
if (truncated) {
|
||||||
@@ -181,7 +182,18 @@ int main(int argc, char** argv) {
|
|||||||
const char* cmd = argv[2];
|
const char* cmd = argv[2];
|
||||||
int rc = 0;
|
int rc = 0;
|
||||||
if (strcmp(cmd, "monitor") == 0) {
|
if (strcmp(cmd, "monitor") == 0) {
|
||||||
int secs = argc > 3 ? atoi(argv[3]) : 60;
|
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) {
|
if ((rc = wait_online(s, 15000)) == 0) {
|
||||||
s->batch_begin();
|
s->batch_begin();
|
||||||
for (auto* info = fgl::aircon::prop_info_begin(tmpl);
|
for (auto* info = fgl::aircon::prop_info_begin(tmpl);
|
||||||
@@ -249,6 +261,7 @@ int main(int argc, char** argv) {
|
|||||||
rc = 2;
|
rc = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup:
|
||||||
print_stats(s);
|
print_stats(s);
|
||||||
s->stop();
|
s->stop();
|
||||||
delete s;
|
delete s;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "FGLair",
|
||||||
|
"homeassistant": "2025.1.0",
|
||||||
|
"render_readme": true
|
||||||
|
}
|
||||||
@@ -12,7 +12,10 @@ extern "C" {
|
|||||||
// Версия библиотеки.
|
// Версия библиотеки.
|
||||||
const char* fgl_version(void);
|
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,
|
typedef void (*fgl_log_sink_fn)(int level, const char* msg, unsigned len,
|
||||||
void* ctx);
|
void* ctx);
|
||||||
void fgl_log_set_sink(fgl_log_sink_fn sink, void* ctx);
|
void fgl_log_set_sink(fgl_log_sink_fn sink, void* ctx);
|
||||||
@@ -130,6 +133,51 @@ typedef struct {
|
|||||||
// Непрозрачная сессия (определение — в реализации).
|
// Непрозрачная сессия (определение — в реализации).
|
||||||
typedef struct fgl_session fgl_session_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,
|
fgl_session_t* fgl_session_create(const fgl_config_t* cfg,
|
||||||
const fgl_callbacks_t* cbs);
|
const fgl_callbacks_t* cbs);
|
||||||
void fgl_session_destroy(fgl_session_t* s);
|
void fgl_session_destroy(fgl_session_t* s);
|
||||||
@@ -156,6 +204,10 @@ 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_name(fgl_template_t t, fgl_prop_t prop); // NULL — нет
|
||||||
const char* fgl_prop_base_type(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); // -1 — нет
|
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 fgl_convert_to_display(fgl_template_t t, fgl_prop_t prop,
|
||||||
int64_t raw);
|
int64_t raw);
|
||||||
int64_t fgl_convert_from_input(fgl_template_t t, fgl_prop_t prop,
|
int64_t fgl_convert_from_input(fgl_template_t t, fgl_prop_t prop,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""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])
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""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())
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Сборка libfgl-aircon.so из корня монорепо (cmake).
|
||||||
|
|
||||||
|
Используется при упаковке wheel (setup.py) и для локальной разработки:
|
||||||
|
|
||||||
|
python -m pyfglair._corebuild --inplace
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
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 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())
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Перечисления 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
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Облачный 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()
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
"""Обёртка сессии 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()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Интроспекция шаблонов 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))
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "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"
|
||||||
|
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"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests/pyfglair
|
||||||
|
filterwarnings =
|
||||||
|
error::ResourceWarning
|
||||||
@@ -27,4 +27,11 @@ if command -v clang++ >/dev/null 2>&1; then
|
|||||||
else
|
else
|
||||||
echo "clang++ не найден — сборка пропущена"
|
echo "clang++ не найден — сборка пропущена"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Python-часть (pyfglair): нужен подготовленный venv (scripts/py-ci.sh --setup).
|
||||||
|
if [ -x "${PY_VENV:-.venv}/bin/python" ]; then
|
||||||
|
bash scripts/py-ci.sh
|
||||||
|
else
|
||||||
|
echo "pyfglair: ${PY_VENV:-.venv} не найден — пропуск (scripts/py-ci.sh --setup)"
|
||||||
|
fi
|
||||||
echo "CI OK"
|
echo "CI OK"
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/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
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Сборка wheel pyfglair: вместе с python-пакетом собирается libfgl-aircon.so
|
||||||
|
из корня монорепо (cmake). Метаданные — в pyproject.toml; здесь только хуки
|
||||||
|
setuptools для сборки нативного ядра."""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _load_corebuild():
|
||||||
|
spec = spec_from_file_location(
|
||||||
|
"_pyfglair_corebuild", ROOT / "pyfglair" / "_corebuild.py"
|
||||||
|
)
|
||||||
|
module = module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
_load_corebuild().build_core(dest=dest, bundled_mbedtls=True)
|
||||||
|
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)."""
|
||||||
|
|
||||||
|
def get_tag(self):
|
||||||
|
_, _, platform = super().get_tag()
|
||||||
|
return "py3", "none", platform
|
||||||
|
|
||||||
|
|
||||||
|
setup(
|
||||||
|
cmdclass={"build_py": build_py, "bdist_wheel": bdist_wheel},
|
||||||
|
distclass=BinaryDistribution,
|
||||||
|
)
|
||||||
@@ -2,8 +2,12 @@
|
|||||||
// обязаны совпадать с C++ enum'ами — проверено static_assert'ами.
|
// обязаны совпадать с C++ enum'ами — проверено static_assert'ами.
|
||||||
#include "fgl-aircon/c_api.h"
|
#include "fgl-aircon/c_api.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <condition_variable>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <mutex>
|
||||||
#include <new>
|
#include <new>
|
||||||
|
|
||||||
#include "fgl-aircon/session.hpp"
|
#include "fgl-aircon/session.hpp"
|
||||||
@@ -52,74 +56,180 @@ static_assert(FGL_VALUE_STRING ==
|
|||||||
const char* fgl_version(void) { return FGL_AIRCON_VERSION_STRING; }
|
const char* fgl_version(void) { return FGL_AIRCON_VERSION_STRING; }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Логирование
|
// Кольцевые буферы событий/логов (fixed-size, без аллокаций в рантайме)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
namespace {
|
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 {
|
struct LogWrap {
|
||||||
fgl_log_sink_fn fn;
|
std::atomic<fgl_log_sink_fn> fn{nullptr};
|
||||||
void* ctx;
|
std::atomic<void*> ctx{nullptr};
|
||||||
};
|
};
|
||||||
LogWrap g_log_wrap;
|
LogWrap g_log_wrap;
|
||||||
|
std::once_flag g_log_installed;
|
||||||
|
|
||||||
void log_wrap_trampoline(int level, const char* msg, size_t len, void*) {
|
void log_wrap_trampoline(int level, const char* msg, size_t len, void*) {
|
||||||
if (g_log_wrap.fn != nullptr) {
|
fgl_log_event_t ev{};
|
||||||
g_log_wrap.fn(level, msg, len, g_log_wrap.ctx);
|
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
|
} // namespace
|
||||||
|
|
||||||
void fgl_log_set_sink(fgl_log_sink_fn sink, void* ctx) {
|
void fgl_log_set_sink(fgl_log_sink_fn sink, void* ctx) {
|
||||||
g_log_wrap.fn = sink;
|
g_log_wrap.ctx.store(ctx, std::memory_order_relaxed);
|
||||||
g_log_wrap.ctx = ctx;
|
g_log_wrap.fn.store(sink, std::memory_order_release);
|
||||||
fgl::aircon::set_log_sink(sink != nullptr ? log_wrap_trampoline : nullptr,
|
// Ядро всегда пишет в кольцевой буфер (его читает pyfglair); sink —
|
||||||
nullptr);
|
// дополнительный прямой колбэк для C/C++ (только малые стеки!).
|
||||||
|
ensure_log_pipe();
|
||||||
}
|
}
|
||||||
|
|
||||||
void fgl_log_set_level(int min_level) {
|
void fgl_log_set_level(int min_level) {
|
||||||
fgl::aircon::set_log_level(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);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Сессия
|
// Сессия
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
namespace {
|
struct fgl_session {
|
||||||
|
fgl::aircon::Session* s;
|
||||||
// Контексты колбэков, хранимые в fgl_session.
|
Ring<fgl_event_t, kSessionEventCap>* events;
|
||||||
struct CbCtx {
|
|
||||||
fgl_on_state_fn on_state;
|
fgl_on_state_fn on_state;
|
||||||
fgl_on_property_fn on_property;
|
fgl_on_property_fn on_property;
|
||||||
void* user;
|
void* user;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
void on_state_trampoline(void* ctx, State st, Error err) {
|
void on_state_trampoline(void* ctx, State st, Error err) {
|
||||||
auto* c = static_cast<CbCtx*>(ctx);
|
auto* s = static_cast<fgl_session*>(ctx);
|
||||||
if (c->on_state != nullptr) {
|
if (s->events != nullptr) {
|
||||||
c->on_state(c->user, static_cast<fgl_state_t>(st),
|
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));
|
static_cast<fgl_error_t>(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void on_property_trampoline(void* ctx,
|
void on_property_trampoline(void* ctx,
|
||||||
const fgl::aircon::PropertyEvent& ev) {
|
const fgl::aircon::PropertyEvent& ev) {
|
||||||
auto* c = static_cast<CbCtx*>(ctx);
|
auto* s = static_cast<fgl_session*>(ctx);
|
||||||
if (c->on_property == nullptr) return;
|
if (s->events != nullptr) {
|
||||||
fgl_property_event_t out{};
|
fgl_event_t out{};
|
||||||
out.prop = static_cast<fgl_prop_t>(ev.prop);
|
out.type = FGL_EVENT_PROPERTY;
|
||||||
out.cmd_id = ev.cmd_id;
|
out.data.property.prop = static_cast<fgl_prop_t>(ev.prop);
|
||||||
out.status = ev.status;
|
out.data.property.cmd_id = ev.cmd_id;
|
||||||
out.value.kind = static_cast<fgl_value_kind_t>(ev.value.kind);
|
out.data.property.status = ev.status;
|
||||||
out.value.i = ev.value.i;
|
out.data.property.value.kind = static_cast<fgl_value_kind_t>(ev.value.kind);
|
||||||
memcpy(out.value.s, ev.value.s, sizeof(out.value.s));
|
out.data.property.value.i = ev.value.i;
|
||||||
c->on_property(c->user, &out);
|
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
|
} // namespace
|
||||||
|
|
||||||
struct fgl_session {
|
|
||||||
fgl::aircon::Session* s;
|
|
||||||
CbCtx* cbctx;
|
|
||||||
};
|
|
||||||
|
|
||||||
fgl_session_t* fgl_session_create(const fgl_config_t* cfg,
|
fgl_session_t* fgl_session_create(const fgl_config_t* cfg,
|
||||||
const fgl_callbacks_t* cbs) {
|
const fgl_callbacks_t* cbs) {
|
||||||
if (cfg == nullptr) return nullptr;
|
if (cfg == nullptr) return nullptr;
|
||||||
@@ -136,37 +246,36 @@ fgl_session_t* fgl_session_create(const fgl_config_t* cfg,
|
|||||||
// Конверсии-override через C-API не задаются (применяются на стороне
|
// Конверсии-override через C-API не задаются (применяются на стороне
|
||||||
// pyfglair при превью/вычислениях; сессия работает в единицах API).
|
// pyfglair при превью/вычислениях; сессия работает в единицах API).
|
||||||
|
|
||||||
auto* cbctx = new (std::nothrow) CbCtx{};
|
auto* out = new (std::nothrow) fgl_session{};
|
||||||
if (cbctx == nullptr) return nullptr;
|
if (out == nullptr) return nullptr;
|
||||||
cbctx->on_state = cbs != nullptr ? cbs->on_state : nullptr;
|
out->events = new (std::nothrow) Ring<fgl_event_t, kSessionEventCap>();
|
||||||
cbctx->on_property = cbs != nullptr ? cbs->on_property : nullptr;
|
if (out->events == nullptr) {
|
||||||
cbctx->user = cbs != nullptr ? cbs->ctx : 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{};
|
Callbacks cpp_cbs{};
|
||||||
cpp_cbs.on_state = on_state_trampoline;
|
cpp_cbs.on_state = on_state_trampoline;
|
||||||
cpp_cbs.on_property = on_property_trampoline;
|
cpp_cbs.on_property = on_property_trampoline;
|
||||||
cpp_cbs.ctx = cbctx;
|
cpp_cbs.ctx = out;
|
||||||
|
|
||||||
auto* s = fgl::aircon::Session::create(cc, cpp_cbs);
|
ensure_log_pipe();
|
||||||
if (s == nullptr) {
|
out->s = fgl::aircon::Session::create(cc, cpp_cbs);
|
||||||
delete cbctx;
|
if (out->s == nullptr) {
|
||||||
|
delete out->events;
|
||||||
|
delete out;
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
auto* out = new (std::nothrow) fgl_session_t();
|
|
||||||
if (out == nullptr) {
|
|
||||||
delete s;
|
|
||||||
delete cbctx;
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
out->s = s;
|
|
||||||
out->cbctx = cbctx;
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
void fgl_session_destroy(fgl_session_t* s) {
|
void fgl_session_destroy(fgl_session_t* s) {
|
||||||
if (s == nullptr) return;
|
if (s == nullptr) return;
|
||||||
delete s->s;
|
delete s->s;
|
||||||
delete s->cbctx;
|
delete s->events;
|
||||||
delete s;
|
delete s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +287,26 @@ void fgl_session_stop(fgl_session_t* s) {
|
|||||||
if (s != nullptr) s->s->stop();
|
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) {
|
fgl_state_t fgl_session_state(const fgl_session_t* s) {
|
||||||
return s != nullptr ? static_cast<fgl_state_t>(s->s->state())
|
return s != nullptr ? static_cast<fgl_state_t>(s->s->state())
|
||||||
: FGL_STATE_IDLE;
|
: FGL_STATE_IDLE;
|
||||||
@@ -262,6 +391,17 @@ int fgl_prop_read_only(fgl_template_t t, fgl_prop_t prop) {
|
|||||||
return info != nullptr ? (info->read_only ? 1 : 0) : -1;
|
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 fgl_convert_to_display(fgl_template_t t, fgl_prop_t prop,
|
||||||
int64_t raw) {
|
int64_t raw) {
|
||||||
return fgl::aircon::convert_to_display(static_cast<Template>(t),
|
return fgl::aircon::convert_to_display(static_cast<Template>(t),
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
#!/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())
|
||||||
@@ -83,6 +83,15 @@ int main(int argc, char** argv) {
|
|||||||
fgl_convert_to_display(FGL_TEMPLATE_A,
|
fgl_convert_to_display(FGL_TEMPLATE_A,
|
||||||
FGL_PROP_DISPLAY_TEMPERATURE,
|
FGL_PROP_DISPLAY_TEMPERATURE,
|
||||||
7000)));
|
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()) {
|
if (!s->start()) {
|
||||||
printf("START_FAILED\n");
|
printf("START_FAILED\n");
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ def scenario_mapping_and_batch():
|
|||||||
check(cname is not None and "fan_speed" in cname, "c_api prop_name")
|
check(cname is not None and "fan_speed" in cname, "c_api prop_name")
|
||||||
conv = runner.wait_line("CCONV 200", 5)
|
conv = runner.wait_line("CCONV 200", 5)
|
||||||
check(conv is not None, "c_api convert 7000 -> 200")
|
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 после выдачи
|
# batch=1 notify: ждём >=2 REG (второй — асинхронный put после выдачи
|
||||||
# батча), затем ассертим ровно один notify=True.
|
# батча), затем ассертим ровно один notify=True.
|
||||||
import time as _time
|
import time as _time
|
||||||
|
|||||||
@@ -292,6 +292,9 @@ class Mock:
|
|||||||
cmd = c.get("cmd", {})
|
cmd = c.get("cmd", {})
|
||||||
if cmd.get("method") == "DELETE":
|
if cmd.get("method") == "DELETE":
|
||||||
print("DELETE", flush=True)
|
print("DELETE", flush=True)
|
||||||
|
# Слот освобождён: следующая сессия должна получить KE.
|
||||||
|
self.crypto = None
|
||||||
|
self.active = False
|
||||||
return
|
return
|
||||||
res = cmd.get("resource", "")
|
res = cmd.get("resource", "")
|
||||||
name = res.split("name=")[-1]
|
name = res.split("name=")[-1]
|
||||||
@@ -382,9 +385,14 @@ def main():
|
|||||||
ap.add_argument("--stale-gap", type=float, default=44.0)
|
ap.add_argument("--stale-gap", type=float, default=44.0)
|
||||||
ap.add_argument("--push-every", type=float, default=0)
|
ap.add_argument("--push-every", type=float, default=0)
|
||||||
ap.add_argument("--fail-first-ke", action="store_true")
|
ap.add_argument("--fail-first-ke", action="store_true")
|
||||||
|
ap.add_argument("--set", action="append", default=[], metavar="NAME=VALUE",
|
||||||
|
help="предустановить свойство mock-модуля (повторяемо)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
mock = Mock(args)
|
mock = Mock(args)
|
||||||
|
for item in args.set:
|
||||||
|
name, _, value = item.partition("=")
|
||||||
|
mock.props[name] = int(value, 0) # принимает 0b/0x/десятичное
|
||||||
lock = mock.lock
|
lock = mock.lock
|
||||||
|
|
||||||
class H(http.server.BaseHTTPRequestHandler):
|
class H(http.server.BaseHTTPRequestHandler):
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Фикстуры тестов HA-компонента fglair."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from helpers import DSN, KEY_ID, LANIP_KEY
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
|
from custom_components.fglair.const import (
|
||||||
|
CONF_DEVICE_PORT,
|
||||||
|
CONF_DSN,
|
||||||
|
CONF_HOST,
|
||||||
|
CONF_LANIP_KEY,
|
||||||
|
CONF_LANIP_KEY_ID,
|
||||||
|
CONF_TEMPLATE,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def auto_enable_custom_integrations(enable_custom_integrations):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def entry_data() -> dict:
|
||||||
|
return {
|
||||||
|
CONF_HOST: "127.0.0.1",
|
||||||
|
CONF_DEVICE_PORT: 9,
|
||||||
|
CONF_DSN: DSN,
|
||||||
|
CONF_LANIP_KEY: LANIP_KEY,
|
||||||
|
CONF_LANIP_KEY_ID: KEY_ID,
|
||||||
|
CONF_TEMPLATE: "A",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_entry(entry_data) -> MockConfigEntry:
|
||||||
|
return MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id=DSN)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Общие данные HA-тестов fglair (импортируется как top-level модуль)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
|
DSN = "AC000W00MOCK0001"
|
||||||
|
LANIP_KEY = "TW9ja0tleUFDbkdvMTIzNDU2Nzg5MDEyMw=="
|
||||||
|
KEY_ID = 64201
|
||||||
|
|
||||||
|
|
||||||
|
def free_port() -> int:
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
return port
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
"""Config flow fglair: меню, ручной ввод, импорт, облако (discover замокан)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from helpers import DSN, KEY_ID, LANIP_KEY
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.config_entries import SOURCE_RECONFIGURE
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
|
import pyfglair
|
||||||
|
from custom_components.fglair.const import DOMAIN
|
||||||
|
from custom_components.fglair.trial import TrialResult
|
||||||
|
from pyfglair import Prop, Value, ValueKind
|
||||||
|
from pyfglair.provision import Device
|
||||||
|
|
||||||
|
MANUAL_INPUT = {
|
||||||
|
"host": "192.168.1.50",
|
||||||
|
"device_port": 80,
|
||||||
|
"dsn": DSN,
|
||||||
|
"lanip_key": LANIP_KEY,
|
||||||
|
"lanip_key_id": KEY_ID,
|
||||||
|
"name": "Bedroom",
|
||||||
|
"model": "AP-WC1E",
|
||||||
|
}
|
||||||
|
|
||||||
|
PREVIEW_INPUT = {
|
||||||
|
"template": "A",
|
||||||
|
"temp_min": 16.0,
|
||||||
|
"temp_max": 30.0,
|
||||||
|
"temp_step": 0.5,
|
||||||
|
"temp_num": 1,
|
||||||
|
"temp_den": 1,
|
||||||
|
"temp_offset": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
TRIAL_OK = TrialResult(ok=True)
|
||||||
|
TRIAL_NETWORK_FAIL = TrialResult(ok=False, reason="unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
async def _menu(hass: HomeAssistant):
|
||||||
|
return await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _select(hass: HomeAssistant, flow_id: str, option: str):
|
||||||
|
return await hass.config_entries.flow.async_configure(
|
||||||
|
flow_id, {"next_step_id": option}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _configure(hass: HomeAssistant, flow_id: str, user_input: dict):
|
||||||
|
return await hass.config_entries.flow.async_configure(flow_id, user_input)
|
||||||
|
|
||||||
|
|
||||||
|
def _suggested(schema, field: str):
|
||||||
|
for key in schema.schema:
|
||||||
|
if getattr(key, "schema", None) == field:
|
||||||
|
description = key.description
|
||||||
|
if isinstance(description, dict):
|
||||||
|
return description.get("suggested_value")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_menu(hass: HomeAssistant):
|
||||||
|
result = await _menu(hass)
|
||||||
|
assert result["type"] == "menu"
|
||||||
|
assert set(result["menu_options"]) == {"cloud", "manual", "import_json"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_success(hass: HomeAssistant):
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
) as trial:
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
assert result["type"] == "form"
|
||||||
|
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "preview"
|
||||||
|
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["title"] == "Bedroom"
|
||||||
|
assert result["result"].unique_id == DSN
|
||||||
|
data = result["data"]
|
||||||
|
assert data["lanip_key_id"] == KEY_ID
|
||||||
|
assert data["template"] == "A"
|
||||||
|
assert data["temp_step"] == 5
|
||||||
|
assert data["overrides"]["adjust_temperature"] == {
|
||||||
|
"num": 1,
|
||||||
|
"den": 1,
|
||||||
|
"offset": 0,
|
||||||
|
"min": 160,
|
||||||
|
"max": 300,
|
||||||
|
}
|
||||||
|
trial.assert_called_once()
|
||||||
|
assert trial.call_args.args[0]["host"] == "192.168.1.50"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preview_template_change_and_conversion(hass: HomeAssistant):
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
|
||||||
|
preview_input = {
|
||||||
|
**PREVIEW_INPUT,
|
||||||
|
"template": "B",
|
||||||
|
"temp_min": 17.0,
|
||||||
|
"temp_max": 28.0,
|
||||||
|
"temp_step": 1.0,
|
||||||
|
"temp_num": 2,
|
||||||
|
"temp_den": 4,
|
||||||
|
"temp_offset": 5,
|
||||||
|
}
|
||||||
|
result = await _configure(hass, result["flow_id"], preview_input)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"]["template"] == "B"
|
||||||
|
assert result["data"]["temp_step"] == 10
|
||||||
|
assert result["data"]["overrides"]["adjust_temperature"] == {
|
||||||
|
"num": 2,
|
||||||
|
"den": 4,
|
||||||
|
"offset": 5,
|
||||||
|
"min": 170,
|
||||||
|
"max": 280,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preview_invalid_range(hass: HomeAssistant):
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
|
||||||
|
result = await _configure(
|
||||||
|
hass,
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
**PREVIEW_INPUT,
|
||||||
|
"template": "B",
|
||||||
|
"temp_min": 30.0,
|
||||||
|
"temp_max": 16.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_range"}
|
||||||
|
assert "Template: B" in result["description_placeholders"]["preview"]
|
||||||
|
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preview_unreachable_range(hass: HomeAssistant):
|
||||||
|
"""Конверсия выводит таблицу за min/max — пустой достижимый диапазон."""
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
|
||||||
|
result = await _configure(
|
||||||
|
hass,
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
**PREVIEW_INPUT,
|
||||||
|
"temp_min": 30.0,
|
||||||
|
"temp_max": 40.0,
|
||||||
|
"temp_num": 1,
|
||||||
|
"temp_den": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_range"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preview_contains_trial_values(hass: HomeAssistant):
|
||||||
|
props = {
|
||||||
|
Prop.DISPLAY_TEMPERATURE: Value(ValueKind.INT, 200),
|
||||||
|
Prop.DEVICE_CAPABILITIES: Value(ValueKind.INT, 0b11011),
|
||||||
|
}
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TrialResult(ok=True, properties=props),
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
|
||||||
|
preview = result["description_placeholders"]["preview"]
|
||||||
|
assert "20.0" in preview
|
||||||
|
assert "0x0000001B" in preview
|
||||||
|
assert "cool" in preview and "heat" in preview
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_connect_error(hass: HomeAssistant):
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_NETWORK_FAIL,
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "cannot_connect"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_key_mismatch_error(hass: HomeAssistant):
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TrialResult(ok=False, reason="key_mismatch"),
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "key_mismatch"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_already_configured(hass: HomeAssistant):
|
||||||
|
existing = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DSN)
|
||||||
|
existing.add_to_hass(hass)
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
|
||||||
|
assert result["type"] == "abort"
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_error_keeps_input(hass: HomeAssistant):
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_NETWORK_FAIL,
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
|
||||||
|
assert result["errors"] == {"base": "cannot_connect"}
|
||||||
|
assert _suggested(result["data_schema"], "host") == MANUAL_INPUT["host"]
|
||||||
|
assert _suggested(result["data_schema"], "dsn") == DSN
|
||||||
|
assert _suggested(result["data_schema"], "lanip_key") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_json_success(hass: HomeAssistant):
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"ip_address": "192.168.1.60",
|
||||||
|
"dsn": DSN,
|
||||||
|
"lanip_key": LANIP_KEY,
|
||||||
|
"lanip_key_id": KEY_ID,
|
||||||
|
"model": "AP-WB2E",
|
||||||
|
"name": "Kitchen",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "import_json")
|
||||||
|
assert result["type"] == "form"
|
||||||
|
result = await _configure(
|
||||||
|
hass, result["flow_id"], {"config_json": payload}
|
||||||
|
)
|
||||||
|
assert result["step_id"] == "preview"
|
||||||
|
assert _suggested(result["data_schema"], "template") == "B"
|
||||||
|
result = await _configure(
|
||||||
|
hass, result["flow_id"], {**PREVIEW_INPUT, "template": "B"}
|
||||||
|
)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"]["host"] == "192.168.1.60"
|
||||||
|
assert result["data"]["template"] == "B"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_json_invalid(hass: HomeAssistant):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "import_json")
|
||||||
|
result = await _configure(
|
||||||
|
hass, result["flow_id"], {"config_json": "not json"}
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_import"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cloud_flow_success(hass: HomeAssistant):
|
||||||
|
device = Device(
|
||||||
|
name="Bedroom",
|
||||||
|
model="AP-WC1E",
|
||||||
|
dsn=DSN,
|
||||||
|
lan_ip="192.168.1.50",
|
||||||
|
lanip_key=LANIP_KEY,
|
||||||
|
lanip_key_id=KEY_ID,
|
||||||
|
region="eu",
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"pyfglair.discover", AsyncMock(return_value=[device])
|
||||||
|
), patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "cloud")
|
||||||
|
assert result["type"] == "form"
|
||||||
|
result = await _configure(
|
||||||
|
hass,
|
||||||
|
result["flow_id"],
|
||||||
|
{"email": "user@example.com", "password": "secret", "region": "eu"},
|
||||||
|
)
|
||||||
|
assert result["step_id"] == "cloud_device"
|
||||||
|
result = await _configure(hass, result["flow_id"], {"dsn": DSN})
|
||||||
|
assert result["step_id"] == "preview"
|
||||||
|
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"]["host"] == "192.168.1.50"
|
||||||
|
assert result["data"]["lanip_key_id"] == KEY_ID
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cloud_no_devices(hass: HomeAssistant):
|
||||||
|
with patch(
|
||||||
|
"pyfglair.discover",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=pyfglair.ProvisionError(
|
||||||
|
"У аккаунта нет устройств", "no_devices"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "cloud")
|
||||||
|
result = await _configure(
|
||||||
|
hass,
|
||||||
|
result["flow_id"],
|
||||||
|
{"email": "user@example.com", "password": "secret", "region": "eu"},
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "no_devices"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cloud_auth_error(hass: HomeAssistant):
|
||||||
|
with patch(
|
||||||
|
"pyfglair.discover",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=pyfglair.ProvisionError("401 unauthorized", "auth")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "cloud")
|
||||||
|
result = await _configure(
|
||||||
|
hass,
|
||||||
|
result["flow_id"],
|
||||||
|
{"email": "user@example.com", "password": "bad", "region": "eu"},
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_auth"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_yaml_import(hass: HomeAssistant):
|
||||||
|
payload = {
|
||||||
|
"ip_address": "192.168.1.70",
|
||||||
|
"dsn": DSN,
|
||||||
|
"lanip_key": LANIP_KEY,
|
||||||
|
"lanip_key_id": KEY_ID,
|
||||||
|
"model": "AP-WF1E",
|
||||||
|
}
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {DOMAIN: payload})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0].data["template"] == "F"
|
||||||
|
assert entries[0].data["host"] == "192.168.1.70"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_yaml_import_invalid(hass: HomeAssistant):
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {DOMAIN: "not-a-dict"})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert hass.config_entries.async_entries(DOMAIN) == []
|
||||||
|
assert not hass.config_entries.flow.async_progress()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_json_null_device_port(hass: HomeAssistant):
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"ip_address": "192.168.1.71",
|
||||||
|
"dsn": DSN,
|
||||||
|
"lanip_key": LANIP_KEY,
|
||||||
|
"lanip_key_id": str(KEY_ID),
|
||||||
|
"device_port": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
) as trial:
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "import_json")
|
||||||
|
result = await _configure(
|
||||||
|
hass, result["flow_id"], {"config_json": payload}
|
||||||
|
)
|
||||||
|
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"]["device_port"] == 80
|
||||||
|
assert trial.call_args.args[0]["device_port"] == 80
|
||||||
|
|
||||||
|
|
||||||
|
RECONFIGURE_ENTRY_DATA = {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"device_port": 9,
|
||||||
|
"dsn": DSN,
|
||||||
|
"lanip_key": LANIP_KEY,
|
||||||
|
"lanip_key_id": KEY_ID,
|
||||||
|
"name": "Old name",
|
||||||
|
"model": "AP-WC1E",
|
||||||
|
"template": "A",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconfigure_success(hass: HomeAssistant, mock_ac, socket_enabled):
|
||||||
|
from test_entities import _setup_entry
|
||||||
|
|
||||||
|
proc = mock_ac(["--set", "operation_mode=6"])
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
|
||||||
|
)
|
||||||
|
assert result["type"] == "menu"
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert _suggested(result["data_schema"], "host") == "127.0.0.1"
|
||||||
|
result = await _configure(
|
||||||
|
hass, result["flow_id"], {**MANUAL_INPUT, "name": "Renamed"}
|
||||||
|
)
|
||||||
|
assert result["step_id"] == "preview"
|
||||||
|
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
|
||||||
|
assert result["type"] == "abort"
|
||||||
|
assert result["reason"] == "reconfigure_successful"
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert entry.data["name"] == "Renamed"
|
||||||
|
assert entry.state.name == "LOADED"
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconfigure_wrong_device(hass: HomeAssistant):
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN, data=RECONFIGURE_ENTRY_DATA, unique_id=DSN
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
|
||||||
|
)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await _configure(
|
||||||
|
hass,
|
||||||
|
result["flow_id"],
|
||||||
|
{**MANUAL_INPUT, "dsn": "AC000W00OTHER001"},
|
||||||
|
)
|
||||||
|
assert result["type"] == "abort"
|
||||||
|
assert result["reason"] == "unique_id_mismatch"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_options_flow_shows_and_updates_key(
|
||||||
|
hass: HomeAssistant, mock_ac, socket_enabled
|
||||||
|
):
|
||||||
|
from test_entities import _setup_entry
|
||||||
|
|
||||||
|
proc = mock_ac(["--set", "operation_mode=6"])
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "init"
|
||||||
|
assert _suggested(result["data_schema"], "lanip_key") == LANIP_KEY
|
||||||
|
assert result["description_placeholders"]["dsn"] == DSN
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"], {"lanip_key": LANIP_KEY}
|
||||||
|
)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert entry.data["lanip_key"] == LANIP_KEY
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_options_flow_new_key_reloads(
|
||||||
|
hass: HomeAssistant, mock_ac, socket_enabled
|
||||||
|
):
|
||||||
|
from test_entities import _setup_entry
|
||||||
|
|
||||||
|
proc = mock_ac(["--set", "operation_mode=6"])
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"], {"lanip_key": "bmV3LWtleQ=="}
|
||||||
|
)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert entry.data["lanip_key"] == "bmV3LWtleQ=="
|
||||||
|
assert entry.state.name in ("LOADED", "SETUP_RETRY")
|
||||||
|
if entry.state.name == "LOADED":
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Диагностика: параметры для ESPHome и маскировка ключа."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from helpers import DSN, LANIP_KEY
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from test_entities import MOCK_SETTINGS, _setup_entry
|
||||||
|
|
||||||
|
from custom_components.fglair.diagnostics import (
|
||||||
|
async_get_config_entry_diagnostics,
|
||||||
|
)
|
||||||
|
from pyfglair import Prop, State
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for(predicate, timeout: float = 20.0):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
result = predicate()
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
raise AssertionError("условие не выполнено за отведённое время")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_diagnostics(hass: HomeAssistant, mock_ac, socket_enabled):
|
||||||
|
proc = mock_ac(MOCK_SETTINGS)
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
await _wait_for(
|
||||||
|
lambda: runtime.client.state == State.ONLINE
|
||||||
|
and runtime.client.cached(Prop.OPERATION_MODE) is not None
|
||||||
|
and runtime.client.cached(Prop.DISPLAY_TEMPERATURE) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
diag = await async_get_config_entry_diagnostics(hass, entry)
|
||||||
|
assert diag["config"]["dsn"] == DSN
|
||||||
|
assert diag["config"]["host"] == "127.0.0.1"
|
||||||
|
assert diag["config"]["template"] == "A"
|
||||||
|
|
||||||
|
assert diag["esp_home"]["lanip_key"] != LANIP_KEY
|
||||||
|
assert "REDACTED" in diag["esp_home"]["lanip_key"]
|
||||||
|
assert diag["esp_home"]["lanip_key_id"] == 64201
|
||||||
|
assert diag["entry_data"]["lanip_key"] == "**REDACTED**"
|
||||||
|
|
||||||
|
assert diag["runtime"]["state"] == "online"
|
||||||
|
assert diag["runtime"]["events_dropped"] == 0
|
||||||
|
assert diag["runtime"]["values"]["operation_mode"]["raw"] == 6
|
||||||
|
assert diag["runtime"]["values"]["operation_mode"]["display"] == 6
|
||||||
|
assert (
|
||||||
|
diag["runtime"]["values"]["display_temperature"]["display"] == 200
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|
||||||
|
unloaded = await async_get_config_entry_diagnostics(hass, entry)
|
||||||
|
assert unloaded["runtime"]["loaded"] is False
|
||||||
|
assert unloaded["config"]["dsn"] == DSN
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""Сущности fglair против mock_ac.py: climate/switch/select/sensor/binary."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from helpers import DSN, KEY_ID, LANIP_KEY
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
|
from custom_components.fglair.const import DOMAIN
|
||||||
|
from pyfglair import State
|
||||||
|
|
||||||
|
MOCK_SETTINGS = [
|
||||||
|
"--set", "operation_mode=6",
|
||||||
|
"--set", "fan_speed=4",
|
||||||
|
"--set", "adjust_temperature=220",
|
||||||
|
"--set", "display_temperature=7000",
|
||||||
|
"--set", "device_capabilities=0b1111111111111111111",
|
||||||
|
"--set", "af_vertical_num_dir=5",
|
||||||
|
"--set", "af_horizontal_num_dir=7",
|
||||||
|
"--set", "af_vertical_direction=2",
|
||||||
|
"--set", "af_horizontal_direction=3",
|
||||||
|
"--set", "op_status=0b1000000010000000000000000",
|
||||||
|
"--set", "economy_mode=0",
|
||||||
|
"--set", "powerful_mode=0",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for(predicate, timeout: float = 20.0):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
result = predicate()
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
raise AssertionError("условие не выполнено за отведённое время")
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_id(hass: HomeAssistant, platform: str, unique_id: str):
|
||||||
|
return er.async_get(hass).async_get_entity_id(platform, DOMAIN, unique_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_entry(hass: HomeAssistant, port: int) -> MockConfigEntry:
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"device_port": port,
|
||||||
|
"dsn": DSN,
|
||||||
|
"lanip_key": LANIP_KEY,
|
||||||
|
"lanip_key_id": KEY_ID,
|
||||||
|
"template": "A",
|
||||||
|
"name": "Mock AC",
|
||||||
|
"listen_port": 0,
|
||||||
|
},
|
||||||
|
unique_id=DSN,
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_entities_snapshot(hass: HomeAssistant, mock_ac, socket_enabled):
|
||||||
|
proc = mock_ac(MOCK_SETTINGS)
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
|
||||||
|
await _wait_for(lambda: runtime.client.state == State.ONLINE)
|
||||||
|
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
|
||||||
|
await _wait_for(
|
||||||
|
lambda: climate_id and hass.states.get(climate_id) is not None
|
||||||
|
and hass.states.get(climate_id).state == "heat"
|
||||||
|
and hass.states.get(climate_id).attributes["swing_mode"] == "off"
|
||||||
|
)
|
||||||
|
|
||||||
|
climate = hass.states.get(climate_id)
|
||||||
|
assert climate.attributes["current_temperature"] == 20.0
|
||||||
|
assert climate.attributes["temperature"] == 22.0
|
||||||
|
assert climate.attributes["fan_mode"] == "auto"
|
||||||
|
assert climate.attributes["preset_mode"] is None
|
||||||
|
assert "heat" in climate.attributes["hvac_modes"]
|
||||||
|
assert "cool" in climate.attributes["hvac_modes"]
|
||||||
|
assert climate.attributes["swing_mode"] == "off"
|
||||||
|
|
||||||
|
room = _entity_id(hass, "sensor", f"{DSN}_display_temperature")
|
||||||
|
await _wait_for(
|
||||||
|
lambda: room and hass.states.get(room) is not None
|
||||||
|
and hass.states.get(room).state == "20.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
connectivity = _entity_id(hass, "binary_sensor", f"{DSN}_connectivity")
|
||||||
|
assert hass.states.get(connectivity).state == "on"
|
||||||
|
|
||||||
|
defrost = _entity_id(hass, "binary_sensor", f"{DSN}_op_status_defrost")
|
||||||
|
await _wait_for(lambda: hass.states.get(defrost).state == "on")
|
||||||
|
maintenance = _entity_id(
|
||||||
|
hass, "binary_sensor", f"{DSN}_op_status_maintenance"
|
||||||
|
)
|
||||||
|
assert hass.states.get(maintenance).state == "off"
|
||||||
|
|
||||||
|
vertical = _entity_id(
|
||||||
|
hass, "select", f"{DSN}_af_vertical_direction"
|
||||||
|
)
|
||||||
|
await _wait_for(
|
||||||
|
lambda: hass.states.get(vertical).attributes["options"]
|
||||||
|
== ["0", "1", "2", "3", "4"]
|
||||||
|
)
|
||||||
|
vertical_state = hass.states.get(vertical)
|
||||||
|
assert vertical_state.state == "2"
|
||||||
|
|
||||||
|
economy = _entity_id(hass, "switch", f"{DSN}_economy_mode")
|
||||||
|
assert hass.states.get(economy).state == "off"
|
||||||
|
|
||||||
|
connection = _entity_id(hass, "sensor", f"{DSN}_connection_state")
|
||||||
|
assert hass.states.get(connection).state == "online"
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_entity_commands(hass: HomeAssistant, mock_ac, socket_enabled):
|
||||||
|
proc = mock_ac(MOCK_SETTINGS)
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
await _wait_for(lambda: runtime.client.state == State.ONLINE)
|
||||||
|
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
|
||||||
|
select_id = _entity_id(hass, "select", f"{DSN}_af_vertical_direction")
|
||||||
|
await _wait_for(
|
||||||
|
lambda: climate_id and hass.states.get(climate_id) is not None
|
||||||
|
and hass.states.get(climate_id).state == "heat"
|
||||||
|
and hass.states.get(select_id).attributes["options"]
|
||||||
|
== ["0", "1", "2", "3", "4"]
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
"climate",
|
||||||
|
"set_hvac_mode",
|
||||||
|
{"entity_id": climate_id, "hvac_mode": "cool"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
assert proc.wait_line("CMD SET operation_mode=3", 10)
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
"climate",
|
||||||
|
"set_temperature",
|
||||||
|
{"entity_id": climate_id, "temperature": 25.5},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
assert proc.wait_line("CMD SET adjust_temperature=255", 10)
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
"climate",
|
||||||
|
"set_fan_mode",
|
||||||
|
{"entity_id": climate_id, "fan_mode": "low"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
assert proc.wait_line("CMD SET fan_speed=1", 10)
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
"climate",
|
||||||
|
"set_preset_mode",
|
||||||
|
{"entity_id": climate_id, "preset_mode": "eco"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
assert proc.wait_line("CMD SET economy_mode=True", 10)
|
||||||
|
assert proc.wait_line("CMD SET powerful_mode=False", 10)
|
||||||
|
|
||||||
|
await _wait_for(
|
||||||
|
lambda: hass.states.get(climate_id).state == "cool"
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
"select",
|
||||||
|
"select_option",
|
||||||
|
{"entity_id": select_id, "option": "4"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
assert proc.wait_line("CMD SET af_vertical_direction=4", 10)
|
||||||
|
|
||||||
|
switch_id = _entity_id(hass, "switch", f"{DSN}_economy_mode")
|
||||||
|
await hass.services.async_call(
|
||||||
|
"switch", "turn_on", {"entity_id": switch_id}, blocking=True
|
||||||
|
)
|
||||||
|
assert proc.wait_line("CMD SET economy_mode=True", 10)
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
"climate", "turn_off", {"entity_id": climate_id}, blocking=True
|
||||||
|
)
|
||||||
|
assert proc.wait_line("CMD SET operation_mode=0", 10)
|
||||||
|
await hass.services.async_call(
|
||||||
|
"climate", "turn_on", {"entity_id": climate_id}, blocking=True
|
||||||
|
)
|
||||||
|
assert proc.wait_line("CMD SET operation_mode=1", 10)
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reload_online(hass: HomeAssistant, mock_ac, socket_enabled):
|
||||||
|
"""После unload тот же прибор снова поднимается до ONLINE."""
|
||||||
|
proc = mock_ac(MOCK_SETTINGS)
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
await _wait_for(lambda: runtime.client.state == State.ONLINE)
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
await _wait_for(lambda: runtime.client.state == State.ONLINE)
|
||||||
|
|
||||||
|
|
||||||
|
RESTRICTED_SETTINGS = [
|
||||||
|
"--set", "operation_mode=3",
|
||||||
|
"--set", "fan_speed=4",
|
||||||
|
"--set", "device_capabilities=0b100001", # cool (0) + fan auto (5)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_capabilities_filter(
|
||||||
|
hass: HomeAssistant, mock_ac, socket_enabled
|
||||||
|
):
|
||||||
|
proc = mock_ac(RESTRICTED_SETTINGS)
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
await _wait_for(lambda: runtime.client.state == State.ONLINE)
|
||||||
|
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
|
||||||
|
await _wait_for(
|
||||||
|
lambda: climate_id and hass.states.get(climate_id) is not None
|
||||||
|
and hass.states.get(climate_id).state == "cool"
|
||||||
|
and "heat" not in hass.states.get(climate_id).attributes["hvac_modes"]
|
||||||
|
)
|
||||||
|
climate = hass.states.get(climate_id)
|
||||||
|
assert "heat" not in climate.attributes["hvac_modes"]
|
||||||
|
assert "cool" in climate.attributes["hvac_modes"]
|
||||||
|
assert climate.attributes["fan_modes"] == ["auto"]
|
||||||
|
assert climate.attributes.get("preset_modes") is None
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_conversion_applied(
|
||||||
|
hass: HomeAssistant, mock_ac, socket_enabled
|
||||||
|
):
|
||||||
|
settings = [
|
||||||
|
"--set", "operation_mode=6",
|
||||||
|
"--set", "adjust_temperature=220",
|
||||||
|
"--set", "display_temperature=7000",
|
||||||
|
]
|
||||||
|
proc = mock_ac(settings)
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"device_port": proc.port,
|
||||||
|
"dsn": DSN,
|
||||||
|
"lanip_key": LANIP_KEY,
|
||||||
|
"lanip_key_id": KEY_ID,
|
||||||
|
"template": "A",
|
||||||
|
"name": "Mock AC",
|
||||||
|
"listen_port": 0,
|
||||||
|
"temp_step": 5,
|
||||||
|
"overrides": {
|
||||||
|
"adjust_temperature": {
|
||||||
|
"num": 1,
|
||||||
|
"den": 1,
|
||||||
|
"offset": 5,
|
||||||
|
"min": 170,
|
||||||
|
"max": 280,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unique_id=DSN,
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
await _wait_for(lambda: runtime.client.state == State.ONLINE)
|
||||||
|
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
|
||||||
|
await _wait_for(
|
||||||
|
lambda: climate_id and hass.states.get(climate_id) is not None
|
||||||
|
and hass.states.get(climate_id).state == "heat"
|
||||||
|
and hass.states.get(climate_id).attributes["temperature"] == 22.5
|
||||||
|
)
|
||||||
|
assert hass.states.get(climate_id).attributes["temperature"] == 22.5
|
||||||
|
assert hass.states.get(climate_id).attributes["min_temp"] == 17.0
|
||||||
|
assert hass.states.get(climate_id).attributes["max_temp"] == 28.0
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
"climate",
|
||||||
|
"set_temperature",
|
||||||
|
{"entity_id": climate_id, "temperature": 25.0},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
assert proc.wait_line("CMD SET adjust_temperature=245", 10)
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_capabilities_zero_is_unknown(
|
||||||
|
hass: HomeAssistant, mock_ac, socket_enabled
|
||||||
|
):
|
||||||
|
"""device_capabilities=0 трактуем как «не сообщено» — режимы не режем."""
|
||||||
|
proc = mock_ac([
|
||||||
|
"--set", "operation_mode=6",
|
||||||
|
"--set", "device_capabilities=0",
|
||||||
|
])
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
await _wait_for(lambda: runtime.client.state == State.ONLINE)
|
||||||
|
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
|
||||||
|
await _wait_for(
|
||||||
|
lambda: climate_id and hass.states.get(climate_id) is not None
|
||||||
|
and hass.states.get(climate_id).state == "heat"
|
||||||
|
)
|
||||||
|
assert "heat" in hass.states.get(climate_id).attributes["hvac_modes"]
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Setup/unload ConfigEntry: клиент стартует, останавливается, ошибки порта."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from helpers import free_port
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
|
from custom_components.fglair.const import CONF_LISTEN_PORT, DOMAIN
|
||||||
|
from pyfglair import Prop, State
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_unload(hass: HomeAssistant, config_entry, socket_enabled):
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
runtime = config_entry.runtime_data
|
||||||
|
assert runtime is not None
|
||||||
|
assert isinstance(runtime.client.state, State)
|
||||||
|
assert runtime.coordinator.data is not None
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert runtime.client.cached(Prop.OPERATION_MODE) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_two_entries_default_port(hass: HomeAssistant, entry_data, socket_enabled):
|
||||||
|
"""Две записи без явного listen_port не конфликтуют (порт 0 → эфемерный)."""
|
||||||
|
first = MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id="DSN-1")
|
||||||
|
second = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={**entry_data, "dsn": "DSN-2"},
|
||||||
|
unique_id="DSN-2",
|
||||||
|
)
|
||||||
|
first.add_to_hass(hass)
|
||||||
|
assert await hass.config_entries.async_setup(first.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
second.add_to_hass(hass)
|
||||||
|
assert await hass.config_entries.async_setup(second.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert first.state.name == "LOADED"
|
||||||
|
assert second.state.name == "LOADED"
|
||||||
|
|
||||||
|
first_runtime = first.runtime_data
|
||||||
|
assert await hass.config_entries.async_unload(first.entry_id)
|
||||||
|
assert first_runtime.client.cached(Prop.OPERATION_MODE) is None
|
||||||
|
assert second.state.name == "LOADED"
|
||||||
|
assert await hass.config_entries.async_unload(second.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_two_entries_same_explicit_port(
|
||||||
|
hass: HomeAssistant, entry_data, socket_enabled
|
||||||
|
):
|
||||||
|
"""Явно заданный один порт на две записи — вторая SETUP_RETRY."""
|
||||||
|
port = free_port()
|
||||||
|
first = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={**entry_data, CONF_LISTEN_PORT: port},
|
||||||
|
unique_id="DSN-1",
|
||||||
|
)
|
||||||
|
first.add_to_hass(hass)
|
||||||
|
assert await hass.config_entries.async_setup(first.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
second = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={**entry_data, "dsn": "DSN-2", CONF_LISTEN_PORT: port},
|
||||||
|
unique_id="DSN-2",
|
||||||
|
)
|
||||||
|
second.add_to_hass(hass)
|
||||||
|
assert not await hass.config_entries.async_setup(second.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert second.state.name == "SETUP_RETRY"
|
||||||
|
assert await hass.config_entries.async_unload(first.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_does_not_block_loop(
|
||||||
|
hass: HomeAssistant, config_entry, socket_enabled
|
||||||
|
):
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
ticks = 0
|
||||||
|
|
||||||
|
async def ticker() -> None:
|
||||||
|
nonlocal ticks
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
ticks += 1
|
||||||
|
|
||||||
|
task = asyncio.create_task(ticker())
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
task.cancel()
|
||||||
|
assert ticks >= 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_port_busy(hass: HomeAssistant, entry_data, socket_enabled):
|
||||||
|
port = free_port()
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", port))
|
||||||
|
sock.listen(1)
|
||||||
|
try:
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={**entry_data, CONF_LISTEN_PORT: port},
|
||||||
|
unique_id=entry_data["dsn"],
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
assert not await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert entry.state.name == "SETUP_RETRY"
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Ручные конверсии/диапазоны (ConversionSet) без HA-рантайма."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from custom_components.fglair.overrides import ConversionSet, LinearOverride
|
||||||
|
from pyfglair import Prop, Template
|
||||||
|
|
||||||
|
|
||||||
|
def test_identity_keeps_api_units():
|
||||||
|
conversions = ConversionSet(Template.A, {}, 5)
|
||||||
|
assert conversions.to_display(Prop.DISPLAY_TEMPERATURE, 200) == 200
|
||||||
|
assert conversions.to_display(Prop.ADJUST_TEMPERATURE, 220) == 220
|
||||||
|
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 220) == 220
|
||||||
|
assert conversions.temperature_range() == (160, 300)
|
||||||
|
|
||||||
|
|
||||||
|
def test_linear_override_roundtrip_and_clamp():
|
||||||
|
override = LinearOverride(
|
||||||
|
num=1, den=1, offset=5, min_value=170, max_value=280
|
||||||
|
)
|
||||||
|
conversions = ConversionSet(
|
||||||
|
Template.A, {Prop.ADJUST_TEMPERATURE: override}, 5
|
||||||
|
)
|
||||||
|
assert conversions.to_display(Prop.ADJUST_TEMPERATURE, 220) == 225
|
||||||
|
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 225) == 220
|
||||||
|
assert conversions.clamp(Prop.ADJUST_TEMPERATURE, 100) == 170
|
||||||
|
assert conversions.clamp(Prop.ADJUST_TEMPERATURE, 999) == 280
|
||||||
|
assert conversions.temperature_range() == (170, 280)
|
||||||
|
|
||||||
|
|
||||||
|
def test_linear_override_scaled():
|
||||||
|
override = LinearOverride(num=2, den=4, offset=0)
|
||||||
|
conversions = ConversionSet(
|
||||||
|
Template.A, {Prop.ADJUST_TEMPERATURE: override}, 5
|
||||||
|
)
|
||||||
|
assert conversions.to_display(Prop.ADJUST_TEMPERATURE, 200) == 100
|
||||||
|
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 100) == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaled_override_clamped_to_table():
|
||||||
|
"""display = raw/2: таблица A -100..450 → достижимо -50..225."""
|
||||||
|
override = LinearOverride(
|
||||||
|
num=1, den=2, offset=0, min_value=160, max_value=300
|
||||||
|
)
|
||||||
|
conversions = ConversionSet(
|
||||||
|
Template.A, {Prop.ADJUST_TEMPERATURE: override}, 5
|
||||||
|
)
|
||||||
|
assert conversions.temperature_range() == (160, 225)
|
||||||
|
assert conversions.clamp(Prop.ADJUST_TEMPERATURE, 250) == 225
|
||||||
|
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 250) == 450
|
||||||
|
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 225) == 450
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_entry_parses_spec():
|
||||||
|
conversions = ConversionSet.from_entry(
|
||||||
|
{
|
||||||
|
"template": "B",
|
||||||
|
"temp_step": 10,
|
||||||
|
"overrides": {
|
||||||
|
"adjust_temperature": {
|
||||||
|
"num": 2,
|
||||||
|
"den": 4,
|
||||||
|
"offset": 5,
|
||||||
|
"min": 170,
|
||||||
|
"max": 280,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert conversions.template == Template.B
|
||||||
|
assert conversions.step_tenths == 10
|
||||||
|
# display = raw/2 + 5: таблица -100..450 → -45..230, override 170..280
|
||||||
|
# → достижимо 170..230.
|
||||||
|
assert conversions.temperature_range() == (170, 230)
|
||||||
|
override = conversions.override(Prop.ADJUST_TEMPERATURE)
|
||||||
|
assert (override.num, override.den, override.offset) == (2, 4, 5)
|
||||||
|
assert conversions.override(Prop.FAN_SPEED).identity
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_entry_defaults():
|
||||||
|
conversions = ConversionSet.from_entry({"template": "F"})
|
||||||
|
assert conversions.step_tenths == 5
|
||||||
|
assert conversions.temperature_range() == (160, 300)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Repair issue при несовпадении LAN-ключа и его fix-flow."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from helpers import DSN, KEY_ID
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
from test_entities import _setup_entry
|
||||||
|
|
||||||
|
from custom_components.fglair.const import DOMAIN
|
||||||
|
from pyfglair import State
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for(predicate, timeout: float = 20.0):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
result = predicate()
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
raise AssertionError("условие не выполнено за отведённое время")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_key_error_creates_and_fixes_issue(
|
||||||
|
hass: HomeAssistant, mock_ac, socket_enabled
|
||||||
|
):
|
||||||
|
assert await async_setup_component(hass, "repairs", {})
|
||||||
|
proc = mock_ac([], key_id=KEY_ID + 1)
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
await _wait_for(lambda: runtime.client.state == State.KEY_ERROR)
|
||||||
|
|
||||||
|
registry = ir.async_get(hass)
|
||||||
|
issue_id = f"key_mismatch_{DSN}"
|
||||||
|
issue = registry.async_get_issue(DOMAIN, issue_id)
|
||||||
|
assert issue is not None
|
||||||
|
assert issue.is_fixable
|
||||||
|
assert issue.translation_key == "key_mismatch"
|
||||||
|
assert issue.translation_placeholders["device"] == "Mock AC"
|
||||||
|
|
||||||
|
flow_manager = hass.data["repairs"]["flow_manager"]
|
||||||
|
result = await flow_manager.async_init(DOMAIN, data={"issue_id": issue_id})
|
||||||
|
assert result["type"] == "form"
|
||||||
|
result = await flow_manager.async_configure(result["flow_id"], {})
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
# Ключ всё ещё неверен: после reload issue создан снова, а не потерян.
|
||||||
|
assert registry.async_get_issue(DOMAIN, issue_id) is not None
|
||||||
|
|
||||||
|
await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconfigure_fixes_key_error(
|
||||||
|
hass: HomeAssistant, mock_ac, socket_enabled
|
||||||
|
):
|
||||||
|
"""Repair-путь целиком: bad key_id → issue → reconfigure → online → issue снят."""
|
||||||
|
from homeassistant.config_entries import SOURCE_RECONFIGURE
|
||||||
|
from test_config_flow import MANUAL_INPUT, PREVIEW_INPUT
|
||||||
|
|
||||||
|
proc = mock_ac(
|
||||||
|
["--set", "operation_mode=6"], key_id=KEY_ID + 1
|
||||||
|
)
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
await _wait_for(lambda: runtime.client.state == State.KEY_ERROR)
|
||||||
|
|
||||||
|
registry = ir.async_get(hass)
|
||||||
|
issue_id = f"key_mismatch_{DSN}"
|
||||||
|
assert registry.async_get_issue(DOMAIN, issue_id) is not None
|
||||||
|
|
||||||
|
# Mock помнит только один app_addr; останавливаем «залипшую» сессию,
|
||||||
|
# чтобы пробная сессия config flow получила KE (штатно это делает reload).
|
||||||
|
await runtime.client.async_stop()
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"next_step_id": "manual"}
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
**MANUAL_INPUT,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"device_port": proc.port,
|
||||||
|
"lanip_key_id": KEY_ID + 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result["step_id"] == "preview", result.get("errors")
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], PREVIEW_INPUT
|
||||||
|
)
|
||||||
|
assert result["type"] == "abort"
|
||||||
|
assert result["reason"] == "reconfigure_successful"
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
await _wait_for(lambda: runtime.client.state == State.ONLINE)
|
||||||
|
assert registry.async_get_issue(DOMAIN, issue_id) is None
|
||||||
|
assert entry.data["lanip_key_id"] == KEY_ID + 1
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Согласованность strings.json / translations en/ru."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||||
|
COMPONENT = ROOT / "custom_components" / "fglair"
|
||||||
|
|
||||||
|
|
||||||
|
def _keys(node, prefix: str = "") -> set[str]:
|
||||||
|
keys: set[str] = set()
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key, value in node.items():
|
||||||
|
keys.add(prefix + key)
|
||||||
|
keys |= _keys(value, prefix + key + ".")
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_en_matches_strings():
|
||||||
|
strings = json.loads((COMPONENT / "strings.json").read_text())
|
||||||
|
english = json.loads((COMPONENT / "translations" / "en.json").read_text())
|
||||||
|
assert strings == english
|
||||||
|
|
||||||
|
|
||||||
|
def test_ru_covers_all_keys():
|
||||||
|
strings = json.loads((COMPONENT / "strings.json").read_text())
|
||||||
|
russian = json.loads((COMPONENT / "translations" / "ru.json").read_text())
|
||||||
|
assert _keys(strings) == _keys(russian)
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholders(node, prefix: str = "") -> dict[str, set[str]]:
|
||||||
|
out: dict[str, set[str]] = {}
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key, value in node.items():
|
||||||
|
out.update(_placeholders(value, prefix + key + "."))
|
||||||
|
elif isinstance(node, str):
|
||||||
|
out[prefix] = set(re.findall(r"{(\w+)}", node))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_ru_placeholders_match():
|
||||||
|
strings = json.loads((COMPONENT / "strings.json").read_text())
|
||||||
|
russian = json.loads((COMPONENT / "translations" / "ru.json").read_text())
|
||||||
|
assert _placeholders(strings) == _placeholders(russian)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Пробное подключение (trial_connect) против mock_ac.py — без HA-рантайма."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from helpers import free_port
|
||||||
|
|
||||||
|
from custom_components.fglair.trial import trial_connect
|
||||||
|
from pyfglair import Prop, State
|
||||||
|
|
||||||
|
|
||||||
|
async def test_trial_online(mock_ac, ac_env, socket_enabled):
|
||||||
|
proc = mock_ac()
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
trial_connect,
|
||||||
|
"127.0.0.1",
|
||||||
|
ac_env["dsn"],
|
||||||
|
ac_env["key"],
|
||||||
|
ac_env["key_id"],
|
||||||
|
device_port=proc.port,
|
||||||
|
listen_port=free_port(),
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
assert result.ok, result.reason
|
||||||
|
assert result.state == State.ONLINE
|
||||||
|
assert result.properties[Prop.OPERATION_MODE].int_value == 6
|
||||||
|
assert result.properties[Prop.FAN_SPEED].int_value == 4
|
||||||
|
|
||||||
|
|
||||||
|
async def test_trial_key_mismatch(mock_ac, ac_env, socket_enabled):
|
||||||
|
proc = mock_ac()
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
trial_connect,
|
||||||
|
"127.0.0.1",
|
||||||
|
ac_env["dsn"],
|
||||||
|
ac_env["key"],
|
||||||
|
ac_env["key_id"] + 1,
|
||||||
|
device_port=proc.port,
|
||||||
|
listen_port=free_port(),
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
assert not result.ok
|
||||||
|
assert result.reason == "key_mismatch"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_trial_unreachable(ac_env, socket_enabled):
|
||||||
|
dead_port = free_port()
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
trial_connect,
|
||||||
|
"127.0.0.1",
|
||||||
|
ac_env["dsn"],
|
||||||
|
ac_env["key"],
|
||||||
|
ac_env["key_id"],
|
||||||
|
device_port=dead_port,
|
||||||
|
listen_port=free_port(),
|
||||||
|
timeout=1.0,
|
||||||
|
)
|
||||||
|
assert not result.ok
|
||||||
|
assert result.reason in ("unreachable", "timeout")
|
||||||
|
assert result.state != State.ONLINE
|
||||||
|
|
||||||
|
|
||||||
|
async def test_trial_no_slots(mock_ac, ac_env, socket_enabled):
|
||||||
|
proc = mock_ac(["--503"])
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
trial_connect,
|
||||||
|
"127.0.0.1",
|
||||||
|
ac_env["dsn"],
|
||||||
|
ac_env["key"],
|
||||||
|
ac_env["key_id"],
|
||||||
|
device_port=proc.port,
|
||||||
|
listen_port=free_port(),
|
||||||
|
timeout=3.0,
|
||||||
|
)
|
||||||
|
assert not result.ok
|
||||||
|
assert result.reason == "no_slots"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pytest-homeassistant-custom-component==0.13.367
|
||||||
|
aiohttp>=3.9
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Общие фикстуры тестов: сборка ядра, mock-облако, mock-модуль."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
if "FGL_AIRCON_LIB" not in os.environ:
|
||||||
|
from pyfglair._corebuild import build_core
|
||||||
|
|
||||||
|
os.environ["FGL_AIRCON_LIB"] = str(build_core())
|
||||||
|
|
||||||
|
MOCK_AC = ROOT / "tests" / "ayla" / "mock_ac.py"
|
||||||
|
|
||||||
|
LANIP_KEY = "TW9ja0tleUFDbkdvMTIzNDU2Nzg5MDEyMw=="
|
||||||
|
KEY_ID = 64201
|
||||||
|
DSN = "AC000W00MOCK0001"
|
||||||
|
|
||||||
|
|
||||||
|
def free_port() -> int:
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
class Proc:
|
||||||
|
"""Subprocess с построчным чтением stdout и ожиданием префиксов."""
|
||||||
|
|
||||||
|
def __init__(self, argv):
|
||||||
|
self.lines: list[str] = []
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.event = threading.Event()
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
)
|
||||||
|
threading.Thread(target=self._reader, daemon=True).start()
|
||||||
|
|
||||||
|
def _reader(self) -> None:
|
||||||
|
assert self.proc.stdout is not None
|
||||||
|
for line in self.proc.stdout:
|
||||||
|
with self.lock:
|
||||||
|
self.lines.append(line.rstrip("\n"))
|
||||||
|
self.event.set()
|
||||||
|
|
||||||
|
def all_lines(self) -> list[str]:
|
||||||
|
with self.lock:
|
||||||
|
return list(self.lines)
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
return "\n".join(self.all_lines())
|
||||||
|
|
||||||
|
def wait_line(self, needle: str, timeout: float) -> str | None:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
with self.lock:
|
||||||
|
for line in self.lines:
|
||||||
|
if needle in line:
|
||||||
|
return line
|
||||||
|
self.event.wait(0.1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.proc.terminate()
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.proc.kill()
|
||||||
|
if self.proc.stdout is not None:
|
||||||
|
self.proc.stdout.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def ac_env() -> dict:
|
||||||
|
return {"dsn": DSN, "key": LANIP_KEY, "key_id": KEY_ID}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def cloud():
|
||||||
|
"""Мок облака Ayla: sign_in / devices / lan.json."""
|
||||||
|
state = {
|
||||||
|
"url": "",
|
||||||
|
"fail_login": False,
|
||||||
|
"no_key": False,
|
||||||
|
"bad_devices": False,
|
||||||
|
"bad_lanip": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def sign_in(request):
|
||||||
|
if state["fail_login"]:
|
||||||
|
return web.json_response({"error": "invalid"}, status=401)
|
||||||
|
return web.json_response({"access_token": "tok-123"})
|
||||||
|
|
||||||
|
async def devices(request):
|
||||||
|
assert request.headers["Authorization"] == "auth_token tok-123"
|
||||||
|
if state["bad_devices"]:
|
||||||
|
return web.json_response([{"device": "not-an-object"}])
|
||||||
|
return web.json_response([
|
||||||
|
{
|
||||||
|
"device": {
|
||||||
|
"dsn": DSN,
|
||||||
|
"product_name": "Bedroom",
|
||||||
|
"oem_model": "AP-WC1E",
|
||||||
|
"lan_ip": "192.168.1.50",
|
||||||
|
"mac": "AA:BB:CC:DD:EE:FF",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
async def lan(request):
|
||||||
|
if state["no_key"]:
|
||||||
|
return web.json_response({"lanip": {}})
|
||||||
|
if state["bad_lanip"]:
|
||||||
|
return web.json_response({"lanip": ["unexpected"]})
|
||||||
|
return web.json_response({
|
||||||
|
"lanip": {"lanip_key": "TW9ja0tleQ==", "lanip_key_id": KEY_ID}
|
||||||
|
})
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_post("/users/sign_in.json", sign_in)
|
||||||
|
app.router.add_get("/apiv1/devices.json", devices)
|
||||||
|
app.router.add_get("/apiv1/dsns/{dsn}/lan.json", lan)
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||||
|
await site.start()
|
||||||
|
port = site._server.sockets[0].getsockname()[1]
|
||||||
|
state["url"] = f"http://127.0.0.1:{port}"
|
||||||
|
yield state
|
||||||
|
await runner.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_ac():
|
||||||
|
procs: list[Proc] = []
|
||||||
|
|
||||||
|
def start(
|
||||||
|
extra: list[str] | None = None, key_id: int | None = None
|
||||||
|
) -> Proc:
|
||||||
|
port = free_port()
|
||||||
|
proc = Proc([
|
||||||
|
sys.executable, str(MOCK_AC),
|
||||||
|
"--port", str(port),
|
||||||
|
"--lanip-key", LANIP_KEY,
|
||||||
|
"--key-id", str(KEY_ID if key_id is None else key_id),
|
||||||
|
*(extra or []),
|
||||||
|
])
|
||||||
|
procs.append(proc)
|
||||||
|
if proc.wait_line("READY", 15) is None:
|
||||||
|
proc.stop()
|
||||||
|
raise RuntimeError("mock_ac не стартовал: " + proc.text())
|
||||||
|
proc.port = port # type: ignore[attr-defined]
|
||||||
|
return proc
|
||||||
|
|
||||||
|
yield start
|
||||||
|
for proc in procs:
|
||||||
|
proc.stop()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""CLI pyfglair: discover против мок-облака и monitor против mock_ac."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from pyfglair.__main__ import main
|
||||||
|
|
||||||
|
from conftest import free_port
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cli_discover_json(cloud, capfd):
|
||||||
|
rc = await asyncio.to_thread(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"discover", "--api-base", cloud["url"],
|
||||||
|
"--email", "user@example.com", "--password", "secret",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert rc == 0
|
||||||
|
out, _ = capfd.readouterr()
|
||||||
|
config = json.loads(out.splitlines()[0])
|
||||||
|
assert config["dsn"] == "AC000W00MOCK0001"
|
||||||
|
assert config["lanip_key_id"] == 64201
|
||||||
|
assert config["ip_address"] == "192.168.1.50"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cli_discover_esphome_secrets(cloud, capfd):
|
||||||
|
rc = await asyncio.to_thread(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"discover", "--api-base", cloud["url"],
|
||||||
|
"--email", "user@example.com", "--password", "secret",
|
||||||
|
"--format", "esphome-secrets",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert rc == 0
|
||||||
|
out, _ = capfd.readouterr()
|
||||||
|
assert 'bedroom_dsn: "AC000W00MOCK0001"' in out
|
||||||
|
assert "bedroom_lanip_key_id: 64201" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cli_discover_error(cloud, capfd):
|
||||||
|
cloud["fail_login"] = True
|
||||||
|
rc = await asyncio.to_thread(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"discover", "--api-base", cloud["url"],
|
||||||
|
"--email", "user@example.com", "--password", "bad",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert rc == 2
|
||||||
|
err = capfd.readouterr().err
|
||||||
|
assert "Ошибка входа" in err
|
||||||
|
assert "Traceback" not in err
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cli_discover_out_file(cloud, capfd, tmp_path):
|
||||||
|
out_file = tmp_path / "config_bedroom.json"
|
||||||
|
rc = await asyncio.to_thread(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"discover", "--api-base", cloud["url"],
|
||||||
|
"--email", "user@example.com", "--password", "secret",
|
||||||
|
"--out", str(out_file),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert rc == 0
|
||||||
|
capfd.readouterr()
|
||||||
|
assert out_file.stat().st_mode & 0o777 == 0o600
|
||||||
|
config = json.loads(out_file.read_text())
|
||||||
|
assert config["dsn"] == "AC000W00MOCK0001"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cli_monitor(mock_ac, ac_env, capfd):
|
||||||
|
proc = mock_ac()
|
||||||
|
rc = await asyncio.to_thread(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"monitor",
|
||||||
|
"--host", "127.0.0.1",
|
||||||
|
"--device-port", str(proc.port),
|
||||||
|
"--dsn", ac_env["dsn"],
|
||||||
|
"--lanip-key", ac_env["key"],
|
||||||
|
"--lanip-key-id", str(ac_env["key_id"]),
|
||||||
|
"--listen-port", str(free_port()),
|
||||||
|
"--duration", "2",
|
||||||
|
"--get", "OperationMode",
|
||||||
|
"--log-level", "3",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert rc == 0
|
||||||
|
out, _ = capfd.readouterr()
|
||||||
|
assert "STATE online" in out
|
||||||
|
assert "PROP OPERATION_MODE = 6" in out
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Низкоуровневые проверки C-API: очередь событий, счётчики, ошибки входа."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pyfglair._cffi import ffi, lib
|
||||||
|
|
||||||
|
|
||||||
|
def _make_session():
|
||||||
|
cfg = ffi.new("fgl_config_t*")
|
||||||
|
cfg.host = ffi.new("char[]", b"127.0.0.1")
|
||||||
|
cfg.device_port = 9
|
||||||
|
cfg.dsn = ffi.new("char[]", b"AC000W00MOCK0001")
|
||||||
|
cfg.lanip_key = ffi.new("char[]", b"key")
|
||||||
|
cfg.lanip_key_id = 1
|
||||||
|
cfg.tmpl = 0
|
||||||
|
cfg.listen_port = 0
|
||||||
|
cfg.keepalive_ms = 1000
|
||||||
|
cfg.max_queue = 40
|
||||||
|
session = lib.fgl_session_create(cfg, ffi.NULL)
|
||||||
|
assert session != ffi.NULL
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_wait_empty():
|
||||||
|
session = _make_session()
|
||||||
|
try:
|
||||||
|
out = ffi.new("fgl_event_t[]", 4)
|
||||||
|
assert lib.fgl_session_poll_events(session, out, 4) == 0
|
||||||
|
assert lib.fgl_session_wait_events(session, out, 4, 0) == 0
|
||||||
|
assert lib.fgl_session_events_dropped(session) == 0
|
||||||
|
finally:
|
||||||
|
lib.fgl_session_destroy(session)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_invalid_args():
|
||||||
|
session = _make_session()
|
||||||
|
try:
|
||||||
|
out = ffi.new("fgl_event_t[]", 4)
|
||||||
|
assert lib.fgl_session_poll_events(session, ffi.NULL, 4) == 0
|
||||||
|
assert lib.fgl_session_poll_events(session, out, 0) == 0
|
||||||
|
assert lib.fgl_session_wait_events(session, out, 0, 0) == 0
|
||||||
|
assert lib.fgl_session_poll_events(ffi.NULL, out, 4) == 0
|
||||||
|
assert lib.fgl_session_events_dropped(ffi.NULL) == 0
|
||||||
|
finally:
|
||||||
|
lib.fgl_session_destroy(session)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_null_config():
|
||||||
|
assert lib.fgl_session_create(ffi.NULL, ffi.NULL) == ffi.NULL
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_poll_empty():
|
||||||
|
out = ffi.new("fgl_log_event_t[]", 4)
|
||||||
|
assert lib.fgl_log_poll_events(out, 4) >= 0
|
||||||
|
assert lib.fgl_log_wait_events(out, 4, 0) >= 0
|
||||||
|
assert lib.fgl_log_events_dropped() >= 0
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Облачный discovery против мок-облака (aiohttp), без сети Ayla."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfglair.provision import ProvisionError, discover, esphome_secrets
|
||||||
|
|
||||||
|
from conftest import DSN, KEY_ID
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_ok(cloud):
|
||||||
|
devices = await discover(
|
||||||
|
"user@example.com", "secret", "eu", base_url=cloud["url"]
|
||||||
|
)
|
||||||
|
assert len(devices) == 1
|
||||||
|
dev = devices[0]
|
||||||
|
assert dev.name == "Bedroom"
|
||||||
|
assert dev.model == "AP-WC1E"
|
||||||
|
assert dev.dsn == DSN
|
||||||
|
assert dev.lan_ip == "192.168.1.50"
|
||||||
|
assert dev.lanip_key == "TW9ja0tleQ=="
|
||||||
|
assert dev.lanip_key_id == KEY_ID
|
||||||
|
assert dev.temp_type == "C"
|
||||||
|
assert dev.mac == "aabbccddeeff"
|
||||||
|
|
||||||
|
cfg = dev.as_config()
|
||||||
|
assert cfg["ip_address"] == "192.168.1.50"
|
||||||
|
assert cfg["app"] == "fglair-eu"
|
||||||
|
|
||||||
|
secrets = esphome_secrets(devices)
|
||||||
|
assert 'bedroom_dsn: "AC000W00MOCK0001"' in secrets
|
||||||
|
assert "bedroom_lanip_key_id: 64201" in secrets
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_bad_login(cloud):
|
||||||
|
cloud["fail_login"] = True
|
||||||
|
with pytest.raises(ProvisionError, match="Ошибка входа"):
|
||||||
|
await discover("user@example.com", "bad", "eu", base_url=cloud["url"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_no_key(cloud):
|
||||||
|
cloud["no_key"] = True
|
||||||
|
with pytest.raises(ProvisionError, match="ключ"):
|
||||||
|
await discover("user@example.com", "secret", "eu", base_url=cloud["url"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_unknown_region():
|
||||||
|
with pytest.raises(ProvisionError, match="регион"):
|
||||||
|
await discover("user@example.com", "secret", "xx")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_malformed_devices(cloud):
|
||||||
|
cloud["bad_devices"] = True
|
||||||
|
with pytest.raises(ProvisionError, match="формат устройства"):
|
||||||
|
await discover("user@example.com", "secret", "eu", base_url=cloud["url"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_malformed_lanip(cloud):
|
||||||
|
cloud["bad_lanip"] = True
|
||||||
|
with pytest.raises(ProvisionError, match="lanip"):
|
||||||
|
await discover("user@example.com", "secret", "eu", base_url=cloud["url"])
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Интеграционный тест pyfglair.Session против mock_ac.py (без железа)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from pyfglair import Config, Prop, Session, State
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_state(states: asyncio.Queue, target: State, timeout: float = 10.0):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
assert remaining > 0, f"не дождались {target!r}"
|
||||||
|
state, error = await asyncio.wait_for(states.get(), remaining)
|
||||||
|
if state == target:
|
||||||
|
return state, error
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_prop(events: asyncio.Queue, prop: Prop, timeout: float = 10.0):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
assert remaining > 0, f"не дождались свойства {prop!r}"
|
||||||
|
event = await asyncio.wait_for(events.get(), remaining)
|
||||||
|
if event.prop == prop:
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def test_session_against_mock(mock_ac, ac_env):
|
||||||
|
proc = mock_ac()
|
||||||
|
states: asyncio.Queue = asyncio.Queue()
|
||||||
|
events: asyncio.Queue = asyncio.Queue()
|
||||||
|
|
||||||
|
cfg = Config(
|
||||||
|
host="127.0.0.1",
|
||||||
|
device_port=proc.port,
|
||||||
|
dsn=ac_env["dsn"],
|
||||||
|
lanip_key=ac_env["key"],
|
||||||
|
lanip_key_id=ac_env["key_id"],
|
||||||
|
keepalive_ms=1000,
|
||||||
|
)
|
||||||
|
session = Session(
|
||||||
|
cfg,
|
||||||
|
on_state=lambda st, err: states.put_nowait((st, err)),
|
||||||
|
on_property=lambda ev: events.put_nowait(ev),
|
||||||
|
)
|
||||||
|
assert session.start()
|
||||||
|
try:
|
||||||
|
state, error = await _wait_state(states, State.ONLINE)
|
||||||
|
assert state == State.ONLINE
|
||||||
|
assert error == 0
|
||||||
|
|
||||||
|
session.batch_begin()
|
||||||
|
assert session.get_prop(Prop.OPERATION_MODE)
|
||||||
|
assert session.get_prop(Prop.FAN_SPEED)
|
||||||
|
assert session.batch_commit()
|
||||||
|
|
||||||
|
op = await _wait_prop(events, Prop.OPERATION_MODE)
|
||||||
|
assert op.value.int_value == 6
|
||||||
|
assert op.cmd_id >= 0
|
||||||
|
fan = await _wait_prop(events, Prop.FAN_SPEED)
|
||||||
|
assert fan.value.int_value == 4
|
||||||
|
|
||||||
|
assert session.set_int(Prop.FAN_SPEED, 2)
|
||||||
|
cached = session.cached(Prop.FAN_SPEED)
|
||||||
|
assert cached is not None and cached.int_value == 2
|
||||||
|
assert proc.wait_line("CMD SET fan_speed=2", 10) is not None
|
||||||
|
finally:
|
||||||
|
await session.async_stop()
|
||||||
|
|
||||||
|
assert session.closed
|
||||||
|
assert session.state == State.IDLE
|
||||||
|
assert proc.wait_line("DELETE", 10) is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_idempotent(mock_ac, ac_env):
|
||||||
|
proc = mock_ac()
|
||||||
|
cfg = Config(
|
||||||
|
host="127.0.0.1",
|
||||||
|
device_port=proc.port,
|
||||||
|
dsn=ac_env["dsn"],
|
||||||
|
lanip_key=ac_env["key"],
|
||||||
|
lanip_key_id=ac_env["key_id"],
|
||||||
|
)
|
||||||
|
session = Session(cfg)
|
||||||
|
assert session.start()
|
||||||
|
await session.async_stop()
|
||||||
|
await session.async_stop()
|
||||||
|
assert session.close() is None
|
||||||
|
assert session.closed
|
||||||
|
assert session.set_int(Prop.FAN_SPEED, 1) is False
|
||||||
|
assert session.cached(Prop.FAN_SPEED) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_offline_module(ac_env):
|
||||||
|
import socket
|
||||||
|
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
dead_port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
cfg = Config(
|
||||||
|
host="127.0.0.1",
|
||||||
|
device_port=dead_port,
|
||||||
|
dsn=ac_env["dsn"],
|
||||||
|
lanip_key=ac_env["key"],
|
||||||
|
lanip_key_id=ac_env["key_id"],
|
||||||
|
keepalive_ms=500,
|
||||||
|
)
|
||||||
|
session = Session(cfg)
|
||||||
|
session.start()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await session.async_stop()
|
||||||
|
assert session.closed
|
||||||
|
assert session.events_dropped == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_from_callback(mock_ac, ac_env):
|
||||||
|
"""Без loop колбэк идёт в reader-потоке; close() оттуда не дедлочится."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
proc = mock_ac()
|
||||||
|
holder: list[Session] = []
|
||||||
|
called = threading.Event()
|
||||||
|
|
||||||
|
def on_state(state, error):
|
||||||
|
if state == State.REGISTERING and holder:
|
||||||
|
holder[0].close()
|
||||||
|
called.set()
|
||||||
|
|
||||||
|
cfg = Config(
|
||||||
|
host="127.0.0.1",
|
||||||
|
device_port=proc.port,
|
||||||
|
dsn=ac_env["dsn"],
|
||||||
|
lanip_key=ac_env["key"],
|
||||||
|
lanip_key_id=ac_env["key_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def build():
|
||||||
|
session = Session(cfg, loop=None, on_state=on_state)
|
||||||
|
holder.append(session)
|
||||||
|
session.start()
|
||||||
|
return session
|
||||||
|
|
||||||
|
session = await asyncio.to_thread(build)
|
||||||
|
assert await asyncio.to_thread(called.wait, 10), "колбэк не пришёл"
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while not session.closed and time.monotonic() < deadline:
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert session.closed
|
||||||
|
assert session.close() is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_log_delivery(mock_ac, ac_env):
|
||||||
|
from pyfglair import set_log_handler, set_log_level
|
||||||
|
|
||||||
|
logs: list[tuple[int, str]] = []
|
||||||
|
set_log_level(0)
|
||||||
|
set_log_handler(lambda level, msg: logs.append((level, msg)))
|
||||||
|
try:
|
||||||
|
proc = mock_ac()
|
||||||
|
cfg = Config(
|
||||||
|
host="127.0.0.1",
|
||||||
|
device_port=proc.port,
|
||||||
|
dsn=ac_env["dsn"],
|
||||||
|
lanip_key=ac_env["key"],
|
||||||
|
lanip_key_id=ac_env["key_id"],
|
||||||
|
keepalive_ms=1000,
|
||||||
|
)
|
||||||
|
session = Session(cfg)
|
||||||
|
session.start()
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while not logs and time.monotonic() < deadline:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
await session.async_stop()
|
||||||
|
finally:
|
||||||
|
set_log_handler(None)
|
||||||
|
set_log_level(1)
|
||||||
|
assert logs, "логи ядра не доставлены"
|
||||||
|
assert all(isinstance(level, int) and message for level, message in logs)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_callbacks_run_in_loop_thread(mock_ac, ac_env):
|
||||||
|
proc = mock_ac()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
seen: list[object] = []
|
||||||
|
|
||||||
|
cfg = Config(
|
||||||
|
host="127.0.0.1",
|
||||||
|
device_port=proc.port,
|
||||||
|
dsn=ac_env["dsn"],
|
||||||
|
lanip_key=ac_env["key"],
|
||||||
|
lanip_key_id=ac_env["key_id"],
|
||||||
|
)
|
||||||
|
session = Session(cfg, on_state=lambda st, err: seen.append(asyncio.get_running_loop()))
|
||||||
|
session.start()
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while not seen:
|
||||||
|
assert time.monotonic() < deadline, "колбэки не пришли"
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
finally:
|
||||||
|
await session.async_stop()
|
||||||
|
assert all(item is loop for item in seen)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Интроспекция шаблонов/конверсий через cffi (сверка с C-таблицами)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pyfglair import (
|
||||||
|
Prop,
|
||||||
|
Template,
|
||||||
|
ValueKind,
|
||||||
|
api_range,
|
||||||
|
convert_from_input,
|
||||||
|
convert_to_display,
|
||||||
|
detect,
|
||||||
|
prop_info,
|
||||||
|
template_info,
|
||||||
|
)
|
||||||
|
from pyfglair._cffi import ffi, lib
|
||||||
|
|
||||||
|
|
||||||
|
def test_version():
|
||||||
|
assert ffi.string(lib.fgl_version()) == b"0.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect():
|
||||||
|
assert detect("AP-WC1E") == Template.A
|
||||||
|
assert detect("AP-WB2E") == Template.B
|
||||||
|
assert detect("AP-WF3E") == Template.F
|
||||||
|
assert detect("UNKNOWN-MODEL") is None
|
||||||
|
assert detect(None) is None
|
||||||
|
assert detect("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_a_properties():
|
||||||
|
info = {p.prop: p for p in template_info(Template.A)}
|
||||||
|
assert info[Prop.OPERATION_MODE].name == "operation_mode"
|
||||||
|
assert info[Prop.OPERATION_MODE].kind == ValueKind.INT
|
||||||
|
assert info[Prop.OPERATION_MODE].read_only is False
|
||||||
|
assert info[Prop.OPERATION_MODE].raw_min == 0
|
||||||
|
assert info[Prop.OPERATION_MODE].raw_max == 6
|
||||||
|
assert info[Prop.DISPLAY_TEMPERATURE].read_only is True
|
||||||
|
assert info[Prop.DISPLAY_TEMPERATURE].base_type == "integer"
|
||||||
|
assert info[Prop.DEVICE_NAME].kind == ValueKind.STRING
|
||||||
|
assert info[Prop.ADJUST_TEMPERATURE].raw_min == -100
|
||||||
|
assert info[Prop.ADJUST_TEMPERATURE].raw_max == 450
|
||||||
|
assert Prop.AF_VERTICAL_MOVE_STEP1 not in info
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_b_and_f():
|
||||||
|
info_b = {p.prop: p for p in template_info(Template.B)}
|
||||||
|
assert Prop.AF_VERTICAL_MOVE_STEP1 in info_b
|
||||||
|
assert Prop.DISPLAY_TEMPERATURE not in info_b
|
||||||
|
info_f = {p.prop: p for p in template_info(Template.F)}
|
||||||
|
assert Prop.DISPLAY_TEMPERATURE in info_f
|
||||||
|
assert Prop.MONITOR1 in info_f
|
||||||
|
assert Prop.FILTER_SIGN_RESET in info_f
|
||||||
|
assert Prop.FILTER_SIGN_RESET_DISPLAY not in info_f
|
||||||
|
|
||||||
|
|
||||||
|
def test_conversions():
|
||||||
|
assert convert_to_display(Template.A, Prop.DISPLAY_TEMPERATURE, 7000) == 200
|
||||||
|
assert convert_to_display(Template.A, Prop.DISPLAY_TEMPERATURE, 5000) == 0
|
||||||
|
assert convert_to_display(Template.A, Prop.ADJUST_TEMPERATURE, 250) == 250
|
||||||
|
assert convert_from_input(Template.A, Prop.DISPLAY_TEMPERATURE, 200) == 7000
|
||||||
|
assert convert_from_input(Template.A, Prop.ADJUST_TEMPERATURE, 220) == 220
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_info_is_cached():
|
||||||
|
assert template_info(Template.A) is template_info(Template.A)
|
||||||
|
assert prop_info(Template.A, Prop.FAN_SPEED) is prop_info(
|
||||||
|
Template.A, Prop.FAN_SPEED
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_range():
|
||||||
|
assert api_range(Template.A, Prop.DISPLAY_TEMPERATURE) == (-100, 450)
|
||||||
|
assert api_range(Template.A, Prop.OPERATION_MODE) == (0, 6)
|
||||||
|
assert api_range(Template.A, Prop.DEVICE_NAME) is None
|
||||||
|
assert api_range(Template.B, Prop.DISPLAY_TEMPERATURE) is None
|
||||||
@@ -208,6 +208,7 @@ def main():
|
|||||||
if args.out:
|
if args.out:
|
||||||
# Ключ устройства — секрет: файл только для владельца.
|
# Ключ устройства — секрет: файл только для владельца.
|
||||||
fd = os.open(args.out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
fd = os.open(args.out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
os.fchmod(fd, 0o600) # и при перезаписи существующего файла
|
||||||
with os.fdopen(fd, "w") as f:
|
with os.fdopen(fd, "w") as f:
|
||||||
f.write(text)
|
f.write(text)
|
||||||
print(f"Записано: {args.out} (права 0600)", file=sys.stderr)
|
print(f"Записано: {args.out} (права 0600)", file=sys.stderr)
|
||||||
|
|||||||