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:
+3
-1
@@ -4,7 +4,9 @@ docs/apk/*.apk
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
.venv*/
|
||||
venv/
|
||||
|
||||
# Локальный конфиг устройства (содержит lanip_key)
|
||||
|
||||
+37
-15
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""pyfglair — локальное управление кондиционерами Fujitsu General (FGLair/Ayla).
|
||||
|
||||
Python-обёртка над C++-ядром fgl-aircon (cffi, ABI-режим). Пакет собирает
|
||||
ядро cmake'ом при упаковке wheel (см. ``python -m pyfglair._corebuild``).
|
||||
|
||||
Модули, требующие нативной библиотеки (``session``, ``templates``),
|
||||
загружаются лениво: provisioning и перечисления доступны без неё.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
from .const import Error, Prop, State, Template, ValueKind
|
||||
from .provision import Device, ProvisionError, REGIONS, discover, esphome_secrets
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
_LAZY: dict[str, tuple[str, str]] = {
|
||||
"Config": ("pyfglair.session", "Config"),
|
||||
"PropertyEvent": ("pyfglair.session", "PropertyEvent"),
|
||||
"Session": ("pyfglair.session", "Session"),
|
||||
"Value": ("pyfglair.session", "Value"),
|
||||
"set_log_handler": ("pyfglair.session", "set_log_handler"),
|
||||
"set_log_level": ("pyfglair.session", "set_log_level"),
|
||||
"PropertyInfo": ("pyfglair.templates", "PropertyInfo"),
|
||||
"template_info": ("pyfglair.templates", "template_info"),
|
||||
"prop_info": ("pyfglair.templates", "prop_info"),
|
||||
"detect": ("pyfglair.templates", "detect"),
|
||||
"api_range": ("pyfglair.templates", "api_range"),
|
||||
"convert_to_display": ("pyfglair.templates", "convert_to_display"),
|
||||
"convert_from_input": ("pyfglair.templates", "convert_from_input"),
|
||||
"ensure_core": ("pyfglair._corebuild", "ensure_core"),
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"Config",
|
||||
"Device",
|
||||
"Error",
|
||||
"Prop",
|
||||
"PropertyEvent",
|
||||
"PropertyInfo",
|
||||
"ProvisionError",
|
||||
"REGIONS",
|
||||
"Session",
|
||||
"State",
|
||||
"Template",
|
||||
"Value",
|
||||
"ValueKind",
|
||||
"api_range",
|
||||
"convert_from_input",
|
||||
"convert_to_display",
|
||||
"detect",
|
||||
"discover",
|
||||
"ensure_core",
|
||||
"esphome_secrets",
|
||||
"prop_info",
|
||||
"set_log_handler",
|
||||
"set_log_level",
|
||||
"template_info",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
entry = _LAZY.get(name)
|
||||
if entry is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = importlib.import_module(entry[0])
|
||||
return getattr(module, entry[1])
|
||||
@@ -0,0 +1,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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
+168
-43
@@ -2,8 +2,11 @@
|
||||
// обязаны совпадать с C++ enum'ами — проверено static_assert'ами.
|
||||
#include "fgl-aircon/c_api.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
|
||||
#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 <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 {
|
||||
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<fgl_event_t, kSessionEventCap>* events;
|
||||
fgl_on_state_fn on_state;
|
||||
fgl_on_property_fn on_property;
|
||||
void* user;
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
void on_state_trampoline(void* ctx, State st, Error err) {
|
||||
auto* c = static_cast<CbCtx*>(ctx);
|
||||
if (c->on_state != nullptr) {
|
||||
c->on_state(c->user, static_cast<fgl_state_t>(st),
|
||||
auto* s = static_cast<fgl_session*>(ctx);
|
||||
if (s->events != nullptr) {
|
||||
fgl_event_t ev{};
|
||||
ev.type = FGL_EVENT_STATE;
|
||||
ev.data.state.state = static_cast<fgl_state_t>(st);
|
||||
ev.data.state.error = static_cast<fgl_error_t>(err);
|
||||
s->events->push(ev);
|
||||
}
|
||||
if (s->on_state != nullptr) {
|
||||
s->on_state(s->user, static_cast<fgl_state_t>(st),
|
||||
static_cast<fgl_error_t>(err));
|
||||
}
|
||||
}
|
||||
|
||||
void on_property_trampoline(void* ctx,
|
||||
const fgl::aircon::PropertyEvent& ev) {
|
||||
auto* c = static_cast<CbCtx*>(ctx);
|
||||
if (c->on_property == nullptr) return;
|
||||
fgl_property_event_t out{};
|
||||
out.prop = static_cast<fgl_prop_t>(ev.prop);
|
||||
out.cmd_id = ev.cmd_id;
|
||||
out.status = ev.status;
|
||||
out.value.kind = static_cast<fgl_value_kind_t>(ev.value.kind);
|
||||
out.value.i = ev.value.i;
|
||||
memcpy(out.value.s, ev.value.s, sizeof(out.value.s));
|
||||
c->on_property(c->user, &out);
|
||||
auto* s = static_cast<fgl_session*>(ctx);
|
||||
if (s->events != nullptr) {
|
||||
fgl_event_t out{};
|
||||
out.type = FGL_EVENT_PROPERTY;
|
||||
out.data.property.prop = static_cast<fgl_prop_t>(ev.prop);
|
||||
out.data.property.cmd_id = ev.cmd_id;
|
||||
out.data.property.status = ev.status;
|
||||
out.data.property.value.kind = static_cast<fgl_value_kind_t>(ev.value.kind);
|
||||
out.data.property.value.i = ev.value.i;
|
||||
memcpy(out.data.property.value.s, ev.value.s,
|
||||
sizeof(out.data.property.value.s));
|
||||
s->events->push(out);
|
||||
}
|
||||
if (s->on_property != nullptr) {
|
||||
fgl_property_event_t out{};
|
||||
out.prop = static_cast<fgl_prop_t>(ev.prop);
|
||||
out.cmd_id = ev.cmd_id;
|
||||
out.status = ev.status;
|
||||
out.value.kind = static_cast<fgl_value_kind_t>(ev.value.kind);
|
||||
out.value.i = ev.value.i;
|
||||
memcpy(out.value.s, ev.value.s, sizeof(out.value.s));
|
||||
s->on_property(s->user, &out);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
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<fgl_event_t, kSessionEventCap>();
|
||||
if (out->events == nullptr) {
|
||||
delete out;
|
||||
return nullptr;
|
||||
}
|
||||
out->on_state = cbs != nullptr ? cbs->on_state : nullptr;
|
||||
out->on_property = cbs != nullptr ? cbs->on_property : nullptr;
|
||||
out->user = cbs != nullptr ? cbs->ctx : nullptr;
|
||||
|
||||
Callbacks cpp_cbs{};
|
||||
cpp_cbs.on_state = on_state_trampoline;
|
||||
cpp_cbs.on_property = on_property_trampoline;
|
||||
cpp_cbs.ctx = 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<fgl_state_t>(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<Template>(t),
|
||||
static_cast<Prop>(prop));
|
||||
if (info == nullptr) return -1;
|
||||
if (info->raw_min == info->raw_max) return 0; // «не определён»
|
||||
if (min != nullptr) *min = info->raw_min;
|
||||
if (max != nullptr) *max = info->raw_max;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int64_t fgl_convert_to_display(fgl_template_t t, fgl_prop_t prop,
|
||||
int64_t raw) {
|
||||
return fgl::aircon::convert_to_display(static_cast<Template>(t),
|
||||
|
||||
@@ -83,6 +83,15 @@ int main(int argc, char** argv) {
|
||||
fgl_convert_to_display(FGL_TEMPLATE_A,
|
||||
FGL_PROP_DISPLAY_TEMPERATURE,
|
||||
7000)));
|
||||
int64_t range_min = 0, range_max = 0;
|
||||
int range_ok = fgl_prop_raw_range(FGL_TEMPLATE_A,
|
||||
FGL_PROP_ADJUST_TEMPERATURE,
|
||||
&range_min, &range_max);
|
||||
printf("CRANGE %d %lld %lld\n", range_ok,
|
||||
static_cast<long long>(range_min), static_cast<long long>(range_max));
|
||||
printf("CRANGE_NONE %d\n",
|
||||
fgl_prop_raw_range(FGL_TEMPLATE_A, FGL_PROP_DEVICE_NAME, nullptr,
|
||||
nullptr));
|
||||
|
||||
if (!s->start()) {
|
||||
printf("START_FAILED\n");
|
||||
|
||||
@@ -51,6 +51,10 @@ def scenario_mapping_and_batch():
|
||||
check(cname is not None and "fan_speed" in cname, "c_api prop_name")
|
||||
conv = runner.wait_line("CCONV 200", 5)
|
||||
check(conv is not None, "c_api convert 7000 -> 200")
|
||||
rng = runner.wait_line("CRANGE 1 -100 450", 5)
|
||||
check(rng is not None, f"c_api raw range adjust_temperature: {rng}")
|
||||
check(runner.wait_line("CRANGE_NONE 0", 5) is not None,
|
||||
"c_api: строковое свойство без диапазона")
|
||||
# batch=1 notify: ждём >=2 REG (второй — асинхронный put после выдачи
|
||||
# батча), затем ассертим ровно один notify=True.
|
||||
import time as _time
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user