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
+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))