ha(H1): исправления по ревью — жизненный цикл сессии, самодостаточный wheel
- C1: единственный владелец stop+destroy — reader-поток; close() только выставляет флаг и ждёт; повторный close и close из колбэка безопасны (регресс-тесты; TSan на close с мёртвым модулем — 0 гонок) - M1: wheel собирается с bundled mbedtls (нет внешней soname mbedcrypto); попутно исправлена bundled-ветка CMake (-lSTATIC) - C-API: счётчики потерянных событий/логов, лог-кольцо без union-балласта, атомарные fn/ctx лог-синка - provision: валидация формата облачных ответов; CLI: человеческие ошибки (exit 2), --out с правами 0600, console_script pyfglair - упаковка: MANIFEST.in (sdist с C++-исходниками), очистка stale .so, readme в метаданных; find_library без ложных .bak - тесты: close-сценарии, доставка логов, ошибки CLI/provision, C-API очередь
This commit is contained in:
+10
-7
@@ -15,7 +15,7 @@ import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
from .const import State, Template, ValueKind
|
||||
from .provision import REGIONS
|
||||
from .provision import REGIONS, ProvisionError
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
@@ -200,13 +200,16 @@ async def _run_monitor(args: argparse.Namespace) -> int:
|
||||
|
||||
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:
|
||||
try:
|
||||
if args.command == "discover":
|
||||
return asyncio.run(_run_discover(args))
|
||||
if args.command == "monitor":
|
||||
return asyncio.run(_run_monitor(args))
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
except ProvisionError as err:
|
||||
print(f"pyfglair: {err}", file=sys.stderr)
|
||||
return 2
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
return 2
|
||||
|
||||
|
||||
|
||||
+7
-3
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import ctypes.util
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from cffi import FFI
|
||||
@@ -149,7 +150,6 @@ typedef struct {
|
||||
fgl_error_t error;
|
||||
} state;
|
||||
fgl_property_event_t property;
|
||||
fgl_log_event_t log;
|
||||
} data;
|
||||
} fgl_event_t;
|
||||
|
||||
@@ -177,6 +177,8 @@ int fgl_session_cached(const fgl_session_t* s, fgl_prop_t prop,
|
||||
int fgl_session_poll_events(fgl_session_t* s, fgl_event_t* out, int max_events);
|
||||
int fgl_session_wait_events(fgl_session_t* s, fgl_event_t* out, int max_events,
|
||||
int timeout_ms);
|
||||
uint64_t fgl_session_events_dropped(const fgl_session_t* s);
|
||||
uint64_t fgl_log_events_dropped(void);
|
||||
int fgl_log_poll_events(fgl_log_event_t* out, int max_events);
|
||||
int fgl_log_wait_events(fgl_log_event_t* out, int max_events, int timeout_ms);
|
||||
|
||||
@@ -203,11 +205,13 @@ def find_library() -> str:
|
||||
raise FileNotFoundError(f"FGL_AIRCON_LIB={env} не существует")
|
||||
return env
|
||||
pkg = Path(__file__).resolve().parent
|
||||
pattern = re.compile(r"^libfgl-aircon\.(so(\.[0-9.]+)?|dylib|\d+\.dylib)$")
|
||||
candidates = sorted(
|
||||
p for p in pkg.glob("libfgl-aircon.so*") if p.is_file()
|
||||
(p for p in pkg.iterdir() if p.is_file() and pattern.match(p.name)),
|
||||
key=lambda p: (p.name.count("."), len(p.name)),
|
||||
)
|
||||
if candidates:
|
||||
return str(max(candidates, key=lambda p: p.stat().st_mtime))
|
||||
return str(candidates[0])
|
||||
system = ctypes.util.find_library("fgl-aircon")
|
||||
if system:
|
||||
return system
|
||||
|
||||
@@ -61,10 +61,13 @@ def build_core(
|
||||
build_dir: Path | None = None,
|
||||
jobs: int | None = None,
|
||||
clean: bool = False,
|
||||
bundled_mbedtls: bool | None = None,
|
||||
) -> Path:
|
||||
"""Собирает shared-ядро и (при dest) копирует его в каталог пакета.
|
||||
|
||||
Возвращает путь к скопированной/собранной библиотеке.
|
||||
Для wheel mbedtls по умолчанию встраивается (bundled), чтобы .so не
|
||||
зависел от системной soname; FGL_BUNDLED_MBEDTLS=0 — использовать
|
||||
системный.
|
||||
"""
|
||||
src = Path(source) if source else source_root()
|
||||
if src is None:
|
||||
@@ -72,6 +75,9 @@ def build_core(
|
||||
"не найден корень fgl-aircon (CMakeLists.txt); задайте "
|
||||
"FGLAIR_SOURCE_ROOT"
|
||||
)
|
||||
if bundled_mbedtls is None:
|
||||
env = os.environ.get("FGL_BUNDLED_MBEDTLS")
|
||||
bundled_mbedtls = env != "0"
|
||||
src = Path(src).resolve()
|
||||
build = Path(build_dir) if build_dir else src / "build-pyfglair"
|
||||
if clean and build.exists():
|
||||
@@ -84,6 +90,7 @@ def build_core(
|
||||
"-DFGL_BUILD_TESTS=OFF",
|
||||
"-DFGL_BUILD_EXAMPLES=OFF",
|
||||
"-DFGL_BUILD_SHARED=ON",
|
||||
"-DFGL_BUNDLED_MBEDTLS=" + ("ON" if bundled_mbedtls else "OFF"),
|
||||
])
|
||||
_run([
|
||||
"cmake", "--build", str(build), "--target", "fgl-aircon-shared",
|
||||
|
||||
@@ -73,9 +73,3 @@ 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}
|
||||
|
||||
+12
-8
@@ -140,13 +140,12 @@ async def _get_devices(session, token, region, base_url, 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")
|
||||
devices = []
|
||||
for item in data:
|
||||
dev = item.get("device") if isinstance(item, dict) else None
|
||||
if not isinstance(dev, dict) or not isinstance(dev.get("dsn"), str):
|
||||
raise ProvisionError("Облако вернуло неожиданный формат устройства")
|
||||
devices.append(dev)
|
||||
return devices
|
||||
|
||||
|
||||
@@ -158,7 +157,12 @@ async def _get_lanip(session, token, region, base_url, insecure, dsn):
|
||||
)
|
||||
if status != 200 or not isinstance(data, dict):
|
||||
raise ProvisionError(f"Ошибка lan.json для {dsn} ({status}): {data}")
|
||||
return data.get("lanip") or {}
|
||||
lanip = data.get("lanip")
|
||||
if lanip is not None and not isinstance(lanip, dict):
|
||||
raise ProvisionError(
|
||||
f"Облако вернуло неожиданный lanip для {dsn}: {lanip!r}"
|
||||
)
|
||||
return lanip or {}
|
||||
|
||||
|
||||
async def discover(
|
||||
|
||||
+62
-19
@@ -56,6 +56,7 @@ class Config:
|
||||
|
||||
_GLOBAL_LOG: Optional[LogHandler] = None
|
||||
_LOG_PUMP: Optional["_LogPump"] = None
|
||||
_LOG_PUMP_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class _LogPump(threading.Thread):
|
||||
@@ -66,7 +67,14 @@ class _LogPump(threading.Thread):
|
||||
|
||||
def run(self) -> None:
|
||||
out = ffi.new("fgl_log_event_t[]", 8)
|
||||
dropped = 0
|
||||
while True:
|
||||
total = int(lib.fgl_log_events_dropped())
|
||||
if total > dropped:
|
||||
_LOGGER.warning(
|
||||
"переполнение очереди логов: потеряно %d", total - dropped
|
||||
)
|
||||
dropped = total
|
||||
count = lib.fgl_log_wait_events(out, 8, 500)
|
||||
handler = _GLOBAL_LOG
|
||||
if handler is None:
|
||||
@@ -84,10 +92,11 @@ class _LogPump(threading.Thread):
|
||||
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()
|
||||
with _LOG_PUMP_LOCK:
|
||||
_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:
|
||||
@@ -119,18 +128,27 @@ class _SessionReader(threading.Thread):
|
||||
def run(self) -> None:
|
||||
out = ffi.new("fgl_event_t[]", 16)
|
||||
finalize = ffi.NULL
|
||||
dropped = 0
|
||||
try:
|
||||
while True:
|
||||
with self._session._lock:
|
||||
if self._session._closed:
|
||||
# Освобождает сессию reader: close() мог быть вызван
|
||||
# из колбэка в этом же потоке (loop отсутствует).
|
||||
# Единственный владелец stop+destroy при закрытии —
|
||||
# reader (защищает от гонки с close() и позволяет
|
||||
# close() из колбэка в этом же потоке).
|
||||
finalize = self._session._session
|
||||
self._session._session = ffi.NULL
|
||||
return
|
||||
raw = self._session._session
|
||||
if raw == ffi.NULL:
|
||||
return
|
||||
total = int(lib.fgl_session_events_dropped(raw))
|
||||
if total > dropped:
|
||||
_LOGGER.warning(
|
||||
"переполнение очереди событий: потеряно %d",
|
||||
total - dropped,
|
||||
)
|
||||
dropped = total
|
||||
count = lib.fgl_session_wait_events(raw, out, 16, 250)
|
||||
for i in range(count):
|
||||
try:
|
||||
@@ -252,6 +270,14 @@ class Session:
|
||||
def closed(self) -> bool:
|
||||
return self._closed
|
||||
|
||||
@property
|
||||
def events_dropped(self) -> int:
|
||||
"""Сколько событий потеряно из-за переполнения очереди (диагностика)."""
|
||||
with self._lock:
|
||||
if self._closed or self._session == ffi.NULL:
|
||||
return 0
|
||||
return int(lib.fgl_session_events_dropped(self._session))
|
||||
|
||||
@property
|
||||
def config(self) -> Config:
|
||||
return self._config
|
||||
@@ -263,26 +289,43 @@ class Session:
|
||||
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)
|
||||
"""Полное завершение сессии (синоним close: stop + destroy)."""
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Останавливает сессию, джойнит reader и освобождает ядро.
|
||||
|
||||
Из колбэка в reader-потоке (Session без loop) возвращается сразу:
|
||||
освобождение завершит сам reader. Повторный вызов — no-op.
|
||||
"""
|
||||
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(
|
||||
"читатель событий не завершился; сессия не освобождена"
|
||||
)
|
||||
return
|
||||
self._reader.join(timeout=10.0)
|
||||
with self._lock:
|
||||
if self._reader.is_alive():
|
||||
_LOGGER.error(
|
||||
"читатель событий не завершился за 10 с; сессия не "
|
||||
"освобождена"
|
||||
)
|
||||
return
|
||||
raw = self._session
|
||||
self._session = ffi.NULL
|
||||
if raw != ffi.NULL:
|
||||
# Reader завершился, не освободив сессию (неожиданное исключение).
|
||||
lib.fgl_session_destroy(raw)
|
||||
|
||||
def __del__(self) -> None:
|
||||
try:
|
||||
if not self._closed:
|
||||
_LOGGER.warning(
|
||||
"Session не закрыта: вызовите close()/async_stop()"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
Reference in New Issue
Block a user