From 0292404756c49be5a56e35196535b12280673286 Mon Sep 17 00:00:00 2001 From: Petr Polezhaev Date: Tue, 29 Sep 2026 11:31:57 +0300 Subject: [PATCH] =?UTF-8?q?ha(H1):=20pyfglair=20=E2=80=94=20cffi-=D1=8F?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=20(=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D1=8C=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9),=20wheel,?= =?UTF-8?q?=20CLI=20discover/monitor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CMake: shared-таргет fgl-aircon-shared (FGL_BUILD_SHARED, PIC, SOVERSION) - C-API: fgl_prop_raw_range + кольцевые очереди событий/логов (fgl_session_poll/wait_events, fgl_log_poll/wait_events): прямые колбэки из потоков ядра со стеком 8 КБ небезопасны для интерпретатора Python - pyfglair: _corebuild (cmake->wheel), _cffi (ABI), Session (reader-поток -> loop.call_soon_threadsafe), templates (интроспекция C-таблиц), provision (aiohttp), CLI discover/monitor + esphome-secrets - setup.py/pyproject: платформенный wheel py3-none-linux_x86_64 с .so - tests/pyfglair: templates, provision (мок-облако), session + CLI против mock_ac.py; scripts/py-ci.sh; ci.sh запускает python-часть --- .gitignore | 4 +- CMakeLists.txt | 52 +++-- README.md | 17 +- docs/PLAN_HOME_ASSISTANT.md | 7 +- include/fgl-aircon/c_api.h | 52 ++++- pyfglair/__init__.py | 69 ++++++ pyfglair/__main__.py | 214 +++++++++++++++++++ pyfglair/_cffi.py | 228 ++++++++++++++++++++ pyfglair/_corebuild.py | 135 ++++++++++++ pyfglair/const.py | 81 +++++++ pyfglair/provision.py | 221 +++++++++++++++++++ pyfglair/py.typed | 0 pyfglair/session.py | 354 +++++++++++++++++++++++++++++++ pyfglair/templates.py | 92 ++++++++ pyproject.toml | 31 +++ scripts/ci.sh | 7 + scripts/py-ci.sh | 21 ++ setup.py | 55 +++++ src/aircon/c_api.cpp | 211 ++++++++++++++---- tests/aircon/aircon_runner.cpp | 9 + tests/aircon/test_aircon_mock.py | 4 + tests/pyfglair/conftest.py | 157 ++++++++++++++ tests/pyfglair/test_cli.py | 63 ++++++ tests/pyfglair/test_provision.py | 49 +++++ tests/pyfglair/test_session.py | 98 +++++++++ tests/pyfglair/test_templates.py | 68 ++++++ 26 files changed, 2236 insertions(+), 63 deletions(-) create mode 100644 pyfglair/__init__.py create mode 100644 pyfglair/__main__.py create mode 100644 pyfglair/_cffi.py create mode 100644 pyfglair/_corebuild.py create mode 100644 pyfglair/const.py create mode 100644 pyfglair/provision.py create mode 100644 pyfglair/py.typed create mode 100644 pyfglair/session.py create mode 100644 pyfglair/templates.py create mode 100644 pyproject.toml create mode 100644 scripts/py-ci.sh create mode 100644 setup.py create mode 100644 tests/pyfglair/conftest.py create mode 100644 tests/pyfglair/test_cli.py create mode 100644 tests/pyfglair/test_provision.py create mode 100644 tests/pyfglair/test_session.py create mode 100644 tests/pyfglair/test_templates.py diff --git a/.gitignore b/.gitignore index 948e816..61f6813 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,9 @@ docs/apk/*.apk # Python __pycache__/ *.pyc -.venv/ +*.egg-info/ +dist/ +.venv*/ venv/ # Локальный конфиг устройства (содержит lanip_key) diff --git a/CMakeLists.txt b/CMakeLists.txt index 28af281..47fd8b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,6 +44,7 @@ else() option(FGL_BUILD_TESTS "Build tests" ON) option(FGL_BUILD_EXAMPLES "Build examples (fglctl)" ON) + option(FGL_BUILD_SHARED "Build shared library (pyfglair/cffi)" ON) option(FGL_BUNDLED_MBEDTLS "Fetch and build Mbed TLS instead of system" OFF) # ---- mbedtls ---- @@ -73,12 +74,14 @@ else() FetchContent_MakeAvailable(mbedtls) set(FGL_MBEDTLS_TARGET mbedcrypto STATIC) set(FGL_MBEDTLS_INCLUDE_DIR "") + # Статический mbedcrypto нужен и для shared-библиотеки (pyfglair). + set_target_properties(mbedcrypto PROPERTIES POSITION_INDEPENDENT_CODE ON) else() set(FGL_MBEDTLS_TARGET "${MBEDTLS_CRYPTO_LIB}") set(FGL_MBEDTLS_INCLUDE_DIR "${MBEDTLS_INCLUDE_DIR}") endif() - add_library(fgl-aircon STATIC + set(FGL_SOURCES src/ayla/crypto.cpp src/ayla/envelope.cpp src/ayla/httpc.cpp @@ -90,20 +93,39 @@ else() src/aircon/tables.cpp src/ayla/platform/posix/platform.cpp ) - target_include_directories(fgl-aircon - PUBLIC - "${CMAKE_CURRENT_SOURCE_DIR}/include" - "${CMAKE_CURRENT_SOURCE_DIR}" - "${FGL_MBEDTLS_INCLUDE_DIR}" - PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/src" - ) - target_link_libraries(fgl-aircon PUBLIC ${FGL_MBEDTLS_TARGET}) - target_compile_features(fgl-aircon PUBLIC cxx_std_20) - target_compile_options(fgl-aircon PRIVATE - -Wall -Wextra -Werror - -fno-exceptions -fno-rtti - ) + + function(fgl_configure_target tgt) + target_include_directories(${tgt} + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}" + "${FGL_MBEDTLS_INCLUDE_DIR}" + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + ) + target_link_libraries(${tgt} PUBLIC ${FGL_MBEDTLS_TARGET}) + target_compile_features(${tgt} PUBLIC cxx_std_20) + target_compile_options(${tgt} PRIVATE + -Wall -Wextra -Werror + -fno-exceptions -fno-rtti + ) + endfunction() + + add_library(fgl-aircon STATIC ${FGL_SOURCES}) + fgl_configure_target(fgl-aircon) + + # Shared-библиотека для cffi/pyfglair (Home Assistant). Имя то же + # (libfgl-aircon.so), чтобы dlopen по имени был предсказуем. + if(FGL_BUILD_SHARED) + add_library(fgl-aircon-shared SHARED ${FGL_SOURCES}) + fgl_configure_target(fgl-aircon-shared) + set_target_properties(fgl-aircon-shared PROPERTIES + OUTPUT_NAME fgl-aircon + POSITION_INDEPENDENT_CODE ON + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR} + ) + endif() if(FGL_BUILD_TESTS) enable_testing() diff --git a/README.md b/README.md index 208fae1..dabccea 100644 --- a/README.md +++ b/README.md @@ -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) src/ayla/ Ayla LAN protocol: crypto, envelope, HTTP, session loop src/aircon/ property templates A/B/F, conversions, API implementation +pyfglair/ Python bindings (cffi) for Home Assistant (docs/PLAN_HOME_ASSISTANT.md) components/ ESPHome external component (planned, docs/PLAN_ESPHOME.md) custom_components/ Home Assistant custom component (planned, docs/PLAN_HOME_ASSISTANT.md) -tests/ ayla (protocol), aircon (conversions), tools +tests/ ayla (protocol), aircon (conversions), pyfglair, tools examples/cli/ fglctl — sample CLI tools/ fglair-discover, protocol probes ``` @@ -78,6 +79,20 @@ session scenarios against a Python mock of the module). `tests/aircon` covers template tables and conversions. No hardware needed. +## pyfglair (Home Assistant) + +`pyfglair/` — python-пакет (cffi, ABI-режим) поверх того же C-ядра: сессия с +доставкой событий в asyncio, интроспекция шаблонов и облачный discovery. + +```sh +pip install . # wheel: cmake собирает libfgl-aircon.so +python -m pyfglair discover --region eu --email you@example.com +python -m pyfglair monitor --config config_home.json --duration 60 +``` + +Тесты (без железа: mock-модуль + мок-облако): +`scripts/py-ci.sh --setup && scripts/py-ci.sh` (или `pytest tests/pyfglair`). + ## Getting the key (`lanip_key`) The key is static (baked into the AC module; only served by the Ayla cloud): diff --git a/docs/PLAN_HOME_ASSISTANT.md b/docs/PLAN_HOME_ASSISTANT.md index eccd41b..aa22421 100644 --- a/docs/PLAN_HOME_ASSISTANT.md +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -31,7 +31,10 @@ Fallback, если сборка wheel станет блокером: сборк * `pyfglair/_corebuild.py` — cmake-сборка при упаковке wheel; * `pyfglair/_cffi.py` — cffi-декларации поверх `include/fgl-aircon/c_api.h`; * `pyfglair/session.py` — обёртка `Session(cfg)`; колбэки ядра → asyncio - через `loop.call_soon_threadsafe`; + через `loop.call_soon_threadsafe`. Транспорт событий — C-очередь + (`fgl_session_wait_events`) и выделенный python-поток: прямые cffi-колбэки + из потоков ядра небезопасны (у session-потока стек 8 КБ — CPython + падает по stack overflow); * `pyfglair/templates.py` — интроспекция шаблонов через cffi-вызовы `fgl_template_info_get` / `fgl_convert_to_display` (для превью в config flow; единый источник данных — C-таблицы, дублей нет); @@ -151,7 +154,7 @@ Security → Long-lived access tokens) и REST API: вызов сервисов | # | Содержимое | |---|-----------| -| H1 | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor | +| H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor | | H2 | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение | | H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности | | H4 | repair, диагностика (ключ для ESPHome), translations | diff --git a/include/fgl-aircon/c_api.h b/include/fgl-aircon/c_api.h index 03e577e..89c9f86 100644 --- a/include/fgl-aircon/c_api.h +++ b/include/fgl-aircon/c_api.h @@ -12,7 +12,10 @@ extern "C" { // Версия библиотеки. const char* fgl_version(void); -// Логирование. +// Логирование. Логи ядра всегда пишутся в кольцевой буфер (см. +// fgl_log_wait_events) — sink лишь дополнительный прямой колбэк. Прямой +// колбэк вызывается из потоков ядра с малым стеком (session-поток — 8 КБ): +// вызывать из него Python нельзя; для cffi используйте очередь. typedef void (*fgl_log_sink_fn)(int level, const char* msg, unsigned len, void* ctx); void fgl_log_set_sink(fgl_log_sink_fn sink, void* ctx); @@ -130,6 +133,49 @@ typedef struct { // Непрозрачная сессия (определение — в реализации). 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; + fgl_log_event_t log; + } 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); + +// Логи ядра (общий кольцевой буфер; включается при создании C-API-сессии). +int fgl_log_poll_events(fgl_log_event_t* out, int max_events); +int fgl_log_wait_events(fgl_log_event_t* out, int max_events, int timeout_ms); + fgl_session_t* fgl_session_create(const fgl_config_t* cfg, const fgl_callbacks_t* cbs); void fgl_session_destroy(fgl_session_t* s); @@ -156,6 +202,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_base_type(fgl_template_t t, fgl_prop_t prop); int fgl_prop_read_only(fgl_template_t t, fgl_prop_t prop); // -1 — нет +// raw-диапазон свойства из таблицы шаблона: 1 — определён (min/max заполнены), +// 0 — диапазона нет, -1 — свойства нет в шаблоне. +int fgl_prop_raw_range(fgl_template_t t, fgl_prop_t prop, int64_t* min, + int64_t* max); int64_t fgl_convert_to_display(fgl_template_t t, fgl_prop_t prop, int64_t raw); int64_t fgl_convert_from_input(fgl_template_t t, fgl_prop_t prop, diff --git a/pyfglair/__init__.py b/pyfglair/__init__.py new file mode 100644 index 0000000..111f4ec --- /dev/null +++ b/pyfglair/__init__.py @@ -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]) diff --git a/pyfglair/__main__.py b/pyfglair/__main__.py new file mode 100644 index 0000000..96380dd --- /dev/null +++ b/pyfglair/__main__.py @@ -0,0 +1,214 @@ +"""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 + + +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) + if args.command == "discover": + return asyncio.run(_run_discover(args)) + if args.command == "monitor": + try: + return asyncio.run(_run_monitor(args)) + except KeyboardInterrupt: + return 130 + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyfglair/_cffi.py b/pyfglair/_cffi.py new file mode 100644 index 0000000..3d6d31a --- /dev/null +++ b/pyfglair/_cffi.py @@ -0,0 +1,228 @@ +"""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 +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; + fgl_log_event_t log; + } 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); +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 + candidates = sorted( + p for p in pkg.glob("libfgl-aircon.so*") if p.is_file() + ) + if candidates: + return str(max(candidates, key=lambda p: p.stat().st_mtime)) + 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() diff --git a/pyfglair/_corebuild.py b/pyfglair/_corebuild.py new file mode 100644 index 0000000..016cba2 --- /dev/null +++ b/pyfglair/_corebuild.py @@ -0,0 +1,135 @@ +"""Сборка 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, +) -> Path: + """Собирает shared-ядро и (при dest) копирует его в каталог пакета. + + Возвращает путь к скопированной/собранной библиотеке. + """ + src = Path(source) if source else source_root() + if src is None: + raise RuntimeError( + "не найден корень fgl-aircon (CMakeLists.txt); задайте " + "FGLAIR_SOURCE_ROOT" + ) + 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", + ]) + _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()) diff --git a/pyfglair/const.py b/pyfglair/const.py new file mode 100644 index 0000000..04fc043 --- /dev/null +++ b/pyfglair/const.py @@ -0,0 +1,81 @@ +"""Перечисления 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 + + +TEMPLATE_NAMES = {Template.A: "A", Template.B: "B", Template.F: "F"} + +STATE_NAMES = {state: state.name.lower() for state in State} +ERROR_NAMES = {error: error.name.lower() for error in Error} diff --git a/pyfglair/provision.py b/pyfglair/provision.py new file mode 100644 index 0000000..5cc442b --- /dev/null +++ b/pyfglair/provision.py @@ -0,0 +1,221 @@ +"""Облачный 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): + """Ошибка входа/списка устройств/ключа (текст — для показа пользователю).""" + + +@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}") from err + except TimeoutError as err: + raise ProvisionError(f"Таймаут запроса ({url})") 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}") + token = data.get("access_token") + if not token: + raise ProvisionError("Вход выполнен без access_token") + 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}") + devices = [ + item.get("device", {}) + for item in data + if isinstance(item, dict) + ] + if any(not dev.get("dsn") for dev in devices): + raise ProvisionError("Облако вернуло устройство без dsn") + 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}") + return data.get("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}") + 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("У аккаунта нет устройств") + 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") + return out + finally: + if own_session: + await session.close() diff --git a/pyfglair/py.typed b/pyfglair/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyfglair/session.py b/pyfglair/session.py new file mode 100644 index 0000000..d95f27b --- /dev/null +++ b/pyfglair/session.py @@ -0,0 +1,354 @@ +"""Обёртка сессии 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 +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 + + +class _LogPump(threading.Thread): + """Единственный на процесс читатель кольца логов ядра.""" + + def __init__(self) -> None: + super().__init__(name="pyfglair-log", daemon=True) + + def run(self) -> None: + out = ffi.new("fgl_log_event_t[]", 8) + while True: + count = lib.fgl_log_wait_events(out, 8, 500) + 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 set_log_handler(handler: Optional[LogHandler]) -> None: + """Глобальный приёмник логов ядра (0=debug..3=error).""" + global _GLOBAL_LOG, _LOG_PUMP + _GLOBAL_LOG = handler + if handler is not None and _LOG_PUMP is None: + _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 + try: + while True: + with self._session._lock: + if self._session._closed: + # Освобождает сессию reader: close() мог быть вызван + # из колбэка в этом же потоке (loop отсутствует). + finalize = self._session._session + self._session._session = ffi.NULL + return + raw = self._session._session + if raw == ffi.NULL: + return + 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._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 and loop.is_running(): + try: + loop.call_soon_threadsafe(handler, *args) + return + 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 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: + with self._lock: + if self._closed: + return + raw = self._session + lib.fgl_session_stop(raw) + + def close(self) -> None: + with self._lock: + if self._closed: + return + self._closed = True + raw = self._session + lib.fgl_session_stop(raw) + if self._reader is threading.current_thread(): + return # сессию освободит reader (см. _SessionReader.run) + self._reader.join(timeout=3.0) + if self._reader.is_alive(): + _LOGGER.error( + "читатель событий не завершился; сессия не освобождена" + ) + + 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() diff --git a/pyfglair/templates.py b/pyfglair/templates.py new file mode 100644 index 0000000..ede28d5 --- /dev/null +++ b/pyfglair/templates.py @@ -0,0 +1,92 @@ +"""Интроспекция шаблонов A/B/F через C-ядро (единый источник — таблицы +src/aircon/tables.cpp; дублей в Python нет).""" +from __future__ import annotations + +from dataclasses import dataclass +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) + + +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) + + +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)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..18b61d9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[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" +requires-python = ">=3.10" +dependencies = [ + "cffi>=1.15", + "aiohttp>=3.9", +] +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: POSIX :: Linux", + "Topic :: Home Automation", +] + +[tool.setuptools] +packages = ["pyfglair"] +include-package-data = true +zip-safe = false + +[tool.setuptools.package-data] +pyfglair = ["libfgl-aircon.so*", "py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests/pyfglair"] +asyncio_mode = "auto" +filterwarnings = ["error::ResourceWarning"] diff --git a/scripts/ci.sh b/scripts/ci.sh index 7f8050f..fa70410 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -27,4 +27,11 @@ if command -v clang++ >/dev/null 2>&1; then else echo "clang++ не найден — сборка пропущена" fi + +# Python-часть (pyfglair): нужен подготовленный venv (scripts/py-ci.sh --setup). +if [ -x "${PY_VENV:-.venv}/bin/python" ]; then + bash scripts/py-ci.sh +else + echo "pyfglair: ${PY_VENV:-.venv} не найден — пропуск (scripts/py-ci.sh --setup)" +fi echo "CI OK" diff --git a/scripts/py-ci.sh b/scripts/py-ci.sh new file mode 100644 index 0000000..16cc859 --- /dev/null +++ b/scripts/py-ci.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# CI python-части репозитория (pyfglair; позже — тесты HA-компонента). +# scripts/py-ci.sh --setup # создать venv и поставить зависимости +# scripts/py-ci.sh # прогнать pytest (ядро соберётся в conftest) +set -euo pipefail +cd "$(dirname "$0")/.." + +VENV="${PY_VENV:-.venv}" + +if [ "${1:-}" = "--setup" ]; then + python3 -m venv "$VENV" + "$VENV/bin/pip" install -q --upgrade pip + "$VENV/bin/pip" install -q cffi aiohttp pytest pytest-asyncio +fi + +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 -q diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..521f623 --- /dev/null +++ b/setup.py @@ -0,0 +1,55 @@ +"""Сборка 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) + _load_corebuild().build_core(dest=dest) + super().run() + + +class BinaryDistribution(Distribution): + """Платформенный wheel (внутри — .so), не py3-none-any.""" + + def has_ext_modules(self): + return True + + +class bdist_wheel(_bdist_wheel): + """Тег py3-none-: .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, +) diff --git a/src/aircon/c_api.cpp b/src/aircon/c_api.cpp index 0d37fa3..0af1197 100644 --- a/src/aircon/c_api.cpp +++ b/src/aircon/c_api.cpp @@ -2,8 +2,11 @@ // обязаны совпадать с C++ enum'ами — проверено static_assert'ами. #include "fgl-aircon/c_api.h" +#include +#include #include #include +#include #include #include "fgl-aircon/session.hpp" @@ -52,74 +55,174 @@ static_assert(FGL_VALUE_STRING == const char* fgl_version(void) { return FGL_AIRCON_VERSION_STRING; } // --------------------------------------------------------------------------- -// Логирование +// Кольцевые буферы событий/логов (fixed-size, без аллокаций в рантайме) // --------------------------------------------------------------------------- namespace { + +template +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 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 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 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; + } +}; + +constexpr size_t kSessionEventCap = 64; +constexpr size_t kLogEventCap = 64; + +Ring g_log_ring; + struct LogWrap { fgl_log_sink_fn fn; void* ctx; }; LogWrap g_log_wrap; +std::once_flag g_log_installed; void log_wrap_trampoline(int level, const char* msg, size_t len, void*) { + fgl_log_event_t ev{}; + ev.level = level; + size_t n = len < sizeof(ev.msg) - 1 ? len : sizeof(ev.msg) - 1; + memcpy(ev.msg, msg, n); + ev.msg[n] = '\0'; + g_log_ring.push(ev); if (g_log_wrap.fn != nullptr) { g_log_wrap.fn(level, msg, len, g_log_wrap.ctx); } } + +void ensure_log_pipe() { + std::call_once(g_log_installed, + [] { fgl::aircon::set_log_sink(log_wrap_trampoline, nullptr); }); +} + } // namespace void fgl_log_set_sink(fgl_log_sink_fn sink, void* ctx) { g_log_wrap.fn = sink; g_log_wrap.ctx = ctx; - fgl::aircon::set_log_sink(sink != nullptr ? log_wrap_trampoline : nullptr, - nullptr); + // Ядро всегда пишет в кольцевой буфер (его читает pyfglair); sink — + // дополнительный прямой колбэк для C/C++ (только малые стеки!). + ensure_log_pipe(); } void fgl_log_set_level(int min_level) { fgl::aircon::set_log_level(min_level); } +int fgl_log_poll_events(fgl_log_event_t* out, int max_events) { + if (out == nullptr || max_events <= 0) return 0; + return g_log_ring.drain(out, max_events); +} + +int fgl_log_wait_events(fgl_log_event_t* out, int max_events, + int timeout_ms) { + if (out == nullptr || max_events <= 0) return 0; + return g_log_ring.wait(out, max_events, timeout_ms); +} + // --------------------------------------------------------------------------- // Сессия // --------------------------------------------------------------------------- -namespace { - -// Контексты колбэков, хранимые в fgl_session. -struct CbCtx { +struct fgl_session { + fgl::aircon::Session* s; + Ring* events; fgl_on_state_fn on_state; fgl_on_property_fn on_property; void* user; }; +namespace { + void on_state_trampoline(void* ctx, State st, Error err) { - auto* c = static_cast(ctx); - if (c->on_state != nullptr) { - c->on_state(c->user, static_cast(st), + auto* s = static_cast(ctx); + if (s->events != nullptr) { + fgl_event_t ev{}; + ev.type = FGL_EVENT_STATE; + ev.data.state.state = static_cast(st); + ev.data.state.error = static_cast(err); + s->events->push(ev); + } + if (s->on_state != nullptr) { + s->on_state(s->user, static_cast(st), static_cast(err)); } } void on_property_trampoline(void* ctx, const fgl::aircon::PropertyEvent& ev) { - auto* c = static_cast(ctx); - if (c->on_property == nullptr) return; - fgl_property_event_t out{}; - out.prop = static_cast(ev.prop); - out.cmd_id = ev.cmd_id; - out.status = ev.status; - out.value.kind = static_cast(ev.value.kind); - out.value.i = ev.value.i; - memcpy(out.value.s, ev.value.s, sizeof(out.value.s)); - c->on_property(c->user, &out); + auto* s = static_cast(ctx); + if (s->events != nullptr) { + fgl_event_t out{}; + out.type = FGL_EVENT_PROPERTY; + out.data.property.prop = static_cast(ev.prop); + out.data.property.cmd_id = ev.cmd_id; + out.data.property.status = ev.status; + out.data.property.value.kind = static_cast(ev.value.kind); + out.data.property.value.i = ev.value.i; + memcpy(out.data.property.value.s, ev.value.s, + sizeof(out.data.property.value.s)); + s->events->push(out); + } + if (s->on_property != nullptr) { + fgl_property_event_t out{}; + out.prop = static_cast(ev.prop); + out.cmd_id = ev.cmd_id; + out.status = ev.status; + out.value.kind = static_cast(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 -struct fgl_session { - fgl::aircon::Session* s; - CbCtx* cbctx; -}; - fgl_session_t* fgl_session_create(const fgl_config_t* cfg, const fgl_callbacks_t* cbs) { if (cfg == nullptr) return nullptr; @@ -136,37 +239,36 @@ fgl_session_t* fgl_session_create(const fgl_config_t* cfg, // Конверсии-override через C-API не задаются (применяются на стороне // pyfglair при превью/вычислениях; сессия работает в единицах API). - auto* cbctx = new (std::nothrow) CbCtx{}; - if (cbctx == nullptr) return nullptr; - cbctx->on_state = cbs != nullptr ? cbs->on_state : nullptr; - cbctx->on_property = cbs != nullptr ? cbs->on_property : nullptr; - cbctx->user = cbs != nullptr ? cbs->ctx : nullptr; + auto* out = new (std::nothrow) fgl_session{}; + if (out == nullptr) return nullptr; + out->events = new (std::nothrow) Ring(); + if (out->events == nullptr) { + delete out; + return nullptr; + } + out->on_state = cbs != nullptr ? cbs->on_state : nullptr; + out->on_property = cbs != nullptr ? cbs->on_property : nullptr; + out->user = cbs != nullptr ? cbs->ctx : nullptr; Callbacks cpp_cbs{}; cpp_cbs.on_state = on_state_trampoline; cpp_cbs.on_property = on_property_trampoline; - cpp_cbs.ctx = cbctx; + cpp_cbs.ctx = out; - auto* s = fgl::aircon::Session::create(cc, cpp_cbs); - if (s == nullptr) { - delete cbctx; + ensure_log_pipe(); + out->s = fgl::aircon::Session::create(cc, cpp_cbs); + if (out->s == nullptr) { + delete out->events; + delete out; 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; } void fgl_session_destroy(fgl_session_t* s) { if (s == nullptr) return; delete s->s; - delete s->cbctx; + delete s->events; delete s; } @@ -178,6 +280,18 @@ void fgl_session_stop(fgl_session_t* s) { if (s != nullptr) s->s->stop(); } +int fgl_session_poll_events(fgl_session_t* s, fgl_event_t* out, + int max_events) { + if (s == nullptr || out == nullptr || max_events <= 0) return 0; + return s->events->drain(out, max_events); +} + +int fgl_session_wait_events(fgl_session_t* s, fgl_event_t* out, + int max_events, int timeout_ms) { + if (s == nullptr || out == nullptr || max_events <= 0) return 0; + return s->events->wait(out, max_events, timeout_ms); +} + fgl_state_t fgl_session_state(const fgl_session_t* s) { return s != nullptr ? static_cast(s->s->state()) : FGL_STATE_IDLE; @@ -262,6 +376,17 @@ int fgl_prop_read_only(fgl_template_t t, fgl_prop_t prop) { return info != nullptr ? (info->read_only ? 1 : 0) : -1; } +int fgl_prop_raw_range(fgl_template_t t, fgl_prop_t prop, int64_t* min, + int64_t* max) { + const PropInfo* info = fgl::aircon::prop_info_find(static_cast