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:
@@ -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())
|
||||
Reference in New Issue
Block a user