ha(H1): pyfglair — cffi-ядро (очередь событий), wheel, CLI discover/monitor

- 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-часть
This commit is contained in:
2026-09-29 11:31:57 +03:00
parent 97842276c8
commit 0292404756
26 changed files with 2236 additions and 63 deletions
+3 -1
View File
@@ -4,7 +4,9 @@ docs/apk/*.apk
# Python # Python
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/ *.egg-info/
dist/
.venv*/
venv/ venv/
# Локальный конфиг устройства (содержит lanip_key) # Локальный конфиг устройства (содержит lanip_key)
+27 -5
View File
@@ -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 ----
@@ -73,12 +74,14 @@ else()
FetchContent_MakeAvailable(mbedtls) FetchContent_MakeAvailable(mbedtls)
set(FGL_MBEDTLS_TARGET mbedcrypto STATIC) set(FGL_MBEDTLS_TARGET mbedcrypto STATIC)
set(FGL_MBEDTLS_INCLUDE_DIR "") set(FGL_MBEDTLS_INCLUDE_DIR "")
# Статический mbedcrypto нужен и для shared-библиотеки (pyfglair).
set_target_properties(mbedcrypto PROPERTIES POSITION_INDEPENDENT_CODE ON)
else() else()
set(FGL_MBEDTLS_TARGET "${MBEDTLS_CRYPTO_LIB}") set(FGL_MBEDTLS_TARGET "${MBEDTLS_CRYPTO_LIB}")
set(FGL_MBEDTLS_INCLUDE_DIR "${MBEDTLS_INCLUDE_DIR}") set(FGL_MBEDTLS_INCLUDE_DIR "${MBEDTLS_INCLUDE_DIR}")
endif() endif()
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,7 +93,9 @@ 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
function(fgl_configure_target tgt)
target_include_directories(${tgt}
PUBLIC PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/include"
"${CMAKE_CURRENT_SOURCE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}"
@@ -98,12 +103,29 @@ else()
PRIVATE PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/src" "${CMAKE_CURRENT_SOURCE_DIR}/src"
) )
target_link_libraries(fgl-aircon PUBLIC ${FGL_MBEDTLS_TARGET}) target_link_libraries(${tgt} PUBLIC ${FGL_MBEDTLS_TARGET})
target_compile_features(fgl-aircon PUBLIC cxx_std_20) target_compile_features(${tgt} PUBLIC cxx_std_20)
target_compile_options(fgl-aircon PRIVATE target_compile_options(${tgt} PRIVATE
-Wall -Wextra -Werror -Wall -Wextra -Werror
-fno-exceptions -fno-rtti -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()
+16 -1
View File
@@ -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 (planned, 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,20 @@ 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`).
## 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):
+5 -2
View File
@@ -31,7 +31,10 @@ 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-таблицы, дублей нет);
@@ -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 (облако/ручной/импорт) + пробное подключение | | H2 | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
| H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности | | H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
| H4 | repair, диагностика (ключ для ESPHome), translations | | H4 | repair, диагностика (ключ для ESPHome), translations |
+51 -1
View File
@@ -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,49 @@ 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;
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, 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 +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_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,
+69
View File
@@ -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])
+214
View File
@@ -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())
+228
View File
@@ -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()
+135
View File
@@ -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())
+81
View File
@@ -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}
+221
View File
@@ -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()
View File
+354
View File
@@ -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()
+92
View File
@@ -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))
+31
View File
@@ -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"]
+7
View File
@@ -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"
+21
View File
@@ -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
+55
View File
@@ -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-<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,
)
+161 -36
View File
@@ -2,8 +2,11 @@
// обязаны совпадать с C++ enum'ами — проверено static_assert'ами. // обязаны совпадать с C++ enum'ами — проверено static_assert'ами.
#include "fgl-aircon/c_api.h" #include "fgl-aircon/c_api.h"
#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,57 +55,161 @@ 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;
}
};
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; fgl_log_sink_fn fn;
void* ctx; void* ctx;
}; };
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*) {
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) { if (g_log_wrap.fn != nullptr) {
g_log_wrap.fn(level, msg, len, g_log_wrap.ctx); 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 } // 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.fn = sink;
g_log_wrap.ctx = ctx; g_log_wrap.ctx = ctx;
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_event_t out{};
out.type = FGL_EVENT_PROPERTY;
out.data.property.prop = static_cast<fgl_prop_t>(ev.prop);
out.data.property.cmd_id = ev.cmd_id;
out.data.property.status = ev.status;
out.data.property.value.kind = static_cast<fgl_value_kind_t>(ev.value.kind);
out.data.property.value.i = ev.value.i;
memcpy(out.data.property.value.s, ev.value.s,
sizeof(out.data.property.value.s));
s->events->push(out);
}
if (s->on_property != nullptr) {
fgl_property_event_t out{}; fgl_property_event_t out{};
out.prop = static_cast<fgl_prop_t>(ev.prop); out.prop = static_cast<fgl_prop_t>(ev.prop);
out.cmd_id = ev.cmd_id; out.cmd_id = ev.cmd_id;
@@ -110,16 +217,12 @@ void on_property_trampoline(void* ctx,
out.value.kind = static_cast<fgl_value_kind_t>(ev.value.kind); out.value.kind = static_cast<fgl_value_kind_t>(ev.value.kind);
out.value.i = ev.value.i; out.value.i = ev.value.i;
memcpy(out.value.s, ev.value.s, sizeof(out.value.s)); memcpy(out.value.s, ev.value.s, sizeof(out.value.s));
c->on_property(c->user, &out); 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 +239,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 +280,18 @@ 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);
}
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 +376,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),
+9
View File
@@ -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");
+4
View File
@@ -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
+157
View File
@@ -0,0 +1,157 @@
"""Общие фикстуры pyfglair-тестов: сборка ядра, 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[2]
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}
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"
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": {}})
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) -> Proc:
port = free_port()
proc = Proc([
sys.executable, str(MOCK_AC),
"--port", str(port),
"--lanip-key", LANIP_KEY,
"--key-id", str(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()
+63
View File
@@ -0,0 +1,63 @@
"""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_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
+49
View File
@@ -0,0 +1,49 @@
"""Облачный 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")
+98
View File
@@ -0,0 +1,98 @@
"""Интеграционный тест 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_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)
+68
View File
@@ -0,0 +1,68 @@
"""Интроспекция шаблонов/конверсий через cffi (сверка с C-таблицами)."""
from __future__ import annotations
from pyfglair import (
Prop,
Template,
ValueKind,
api_range,
convert_from_input,
convert_to_display,
detect,
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_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