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:
@@ -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