Files
fgl-aircon/tests/ayla/test_session_mock.py
Petr Polezhaev e74f3dc67a core(M2): машина состояний сессии Ayla LAN + mock-модуль + интеграционные сценарии
- session.{hpp,cpp}: state machine (idle/registering/online/recovering/
  offline/key_error); httpd-обработчики key_exchange (200/426/412, re-key
  прозрачно), commands (одна команда, 206/200, envelope, глобальный seq_no),
  datapoint (unpack -> PropertyEvent / 401+тишина 50с для re-key-восстановления);
  сессионный поток: local_reg POST?dsn/PUT (local_ip_for), keep-alive, backoff
  x1.6->60с, 503->offline/NoSlot, activation-timeout->recovering, delete_session
  с ожиданием выдачи; очередь с coalescing + batch; телеметрия; колбэки из
  двух потоков с задокументированным контрактом; буферы datapoint-пути в Impl.
- platform: local_ip_for (UDP-connect) posix+esp-idf; стек httpd 24576
  (переполнение 16КБ поймано gdb на Release).
- mock_ac.py: мок-модуль, stdlib-only чистый python AES-256 (свёрстан с
  pycryptodome); сценарии: 503, no-poll, rekey-every, stale-gap (эмуляция
  'вернувшегося' приложения), fail-pushes (битая подпись), garbage-pushes
  (обрыв блока), break-outbound (исходящий десинк -> модуль ре-кает на
  local_reg, как probe1-3), push-every, fail-first-ke.
- session_runner + test_session_mock.py: 9 сценариев через ctest, включая
  самосинхронизацию CBC и восстановление после исходящего десинка.
- Прибор AP-WC1E: активация <=1с; re-key семантика ИСПРАВЛЕНА по живым
  тестам: re-key при зазоре local_reg >= ~44-50с (не по возрасту сессии!);
  при честном keep-alive 15с сессия стабильна без re-key; PROTOCOL/LEGACY/
  PLAN обновлены; восстановление = тишина >порога + возврат.
- CI: 7/7 x3 (gcc-Rel, gcc-ASan/UBSan, clang); ESP-IDF esp32 build complete.
Ревью под-агентом: 2 круга (стек httpd, залипание состояний, dangling cfg,
физика десинка) — APPROVED.
2026-09-27 14:56:47 +03:00

296 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Интеграционные сценарии сессии против mock-модуля (mock_ac.py).
usage: test_session_mock.py <session_runner> <mock_ac.py>
Сценарии: normal, rekey, fail_push, slot_503, no_poll, bad_ke, set_get.
"""
import os
import socket
import subprocess
import sys
import tempfile
import threading
import time
RUNNER = sys.argv[1]
MOCK = sys.argv[2]
# Синтетический ключ (НЕ боевой).
LANIP_KEY = "TW9ja0tleUFDbkdvMTIzNDU2Nzg5MDEyMw=="
KEY_ID = 64201
DSN = "AC000W00MOCK0001"
def free_port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
class Proc:
"""Процесс с построчным логом stdout."""
def __init__(self, argv, env=None):
self.lines = []
self.lock = threading.Lock()
self.ev = threading.Event()
self.proc = subprocess.Popen(
argv, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True)
threading.Thread(target=self._reader, daemon=True).start()
def _reader(self):
for line in self.proc.stdout:
with self.lock:
self.lines.append(line.rstrip("\n"))
self.ev.set()
def wait_line(self, prefix, timeout, exclude_prefix=None):
"""Ждёт строку, начинающуюся с prefix (или содержащую, если prefix
начинается с '~')."""
contains = prefix.startswith("~")
needle = prefix[1:] if contains else prefix
deadline = time.time() + timeout
seen = 0
while time.time() < deadline:
with self.lock:
for i, line in enumerate(self.lines):
if i < seen:
continue
hit = (needle in line) if contains else line.startswith(prefix)
if hit:
if exclude_prefix and line.startswith(exclude_prefix):
continue
return line
seen = i + 1
self.ev.wait(0.2)
return None
def all_lines(self):
with self.lock:
return list(self.lines)
def stop(self, timeout=10):
self.proc.terminate()
try:
self.proc.wait(timeout)
except subprocess.TimeoutExpired:
self.proc.kill()
def run_mock(extra, port):
p = Proc([sys.executable, MOCK, "--port", str(port), "--lanip-key", LANIP_KEY,
"--key-id", str(KEY_ID)] + extra)
if p.wait_line("READY", 10) is None:
raise RuntimeError("mock не стартовал: " + "\n".join(p.all_lines()))
return p
def run_runner(mock_port, listen_port, props, duration, env_extra=None,
timeout=None):
env = dict(os.environ)
if env_extra:
env.update(env_extra)
argv = [RUNNER, "127.0.0.1", str(mock_port), str(listen_port), DSN,
LANIP_KEY, str(KEY_ID), str(duration)] + props
return Proc(argv, env=env), (timeout or duration + 25)
FAILURES = []
def check(cond, what):
tag = "ok " if cond else "FAIL"
print(f" [{tag}] {what}")
if not cond:
FAILURES.append(what)
def scenario_normal():
print("== scenario: normal (установка, батч GET, push, delete)")
mock_port, listen = free_port(), free_port()
mock = run_mock([], mock_port)
runner, timeout = run_runner(mock_port, listen,
["operation_mode", "fan_speed"], 3)
line = runner.wait_line("STATE online", 10)
check(line is not None, "переход в online")
p1 = runner.wait_line("PROP operation_mode", 10)
p2 = runner.wait_line("PROP fan_speed", 10)
check(p1 is not None and "cid=" not in p1 and " i:6" in p1, f"push #1: {p1}")
check(p2 is not None and " i:4" in p2, f"push #2: {p2}")
check(runner.wait_line("DELETED", timeout) is not None, "delete_session")
check(runner.wait_line("STATS", 5) is not None, "STATS")
check("rekeys=1" in " ".join(runner.all_lines()), "ровно 1 key exchange")
check("CMD GET operation_mode" in "\n".join(mock.all_lines()),
"mock исполнил GET")
check("DELETE" in "\n".join(mock.all_lines()), "mock получил DELETE")
runner.stop()
mock.stop()
def scenario_rekey():
print("== scenario: rekey по local_reg (chain непрерывна)")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--rekey-every", "2", "--push-every", "1"], mock_port)
runner, timeout = run_runner(mock_port, listen, [], 6,
env_extra={"RUNNER_KEEPALIVE_MS": "1000"})
check(runner.wait_line("STATE online", 10) is not None, "online")
t1 = runner.wait_line("PROP tick", 15)
t2 = runner.wait_line("PROP tick", 15)
check(t1 is not None and t2 is not None, "спонтанные push после re-key")
runner.wait_line("DELETED", timeout)
stats = "\n".join(runner.all_lines())
check("rekeys=" in stats and "pushes_bad=0" in stats,
f"без потерянных push: {stats.splitlines()[-1] if stats else '?'}")
check("CMD bad-sign" not in "\n".join(mock.all_lines()),
"mock проверял подписи наших команд")
runner.stop()
mock.stop()
def scenario_fail_push():
print("== scenario: push с битой подписью -> recovering -> online")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--push-every", "1", "--fail-pushes", "1"], mock_port)
runner, timeout = run_runner(mock_port, listen, [], 8,
env_extra={"RUNNER_KEEPALIVE_MS": "1000"})
check(runner.wait_line("STATE online", 10) is not None, "online")
rec = runner.wait_line("STATE recovering 6", 15)
check(rec is not None, f"recovering после битой подписи: {rec}")
# Следующий push расшифровывается (цепочка в синке) -> online.
check(runner.wait_line("STATE online 0", 15) is not None,
"online восстановлен следующим валидным push")
check(runner.wait_line("STATS", timeout) is not None, "STATS получены")
stats = "\n".join(runner.all_lines())
stats_line = [l for l in stats.splitlines() if l.startswith("STATS")]
check("pushes_bad=1" in stats, f"учтён 1 битый push: {stats_line}")
runner.stop()
mock.stop()
def scenario_slot_503():
print("== scenario: 503 — нет слотов")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--503"], mock_port)
runner, timeout = run_runner(mock_port, listen, [], 4,
env_extra={"RUNNER_NOSLOT_RETRY_MS": "2000"})
line = runner.wait_line("STATE offline 1", 10)
check(line is not None, "offline с ошибкой NoSlot")
regs = [l for l in mock.all_lines() if l.startswith("REG")]
check(len(regs) >= 1, "local_reg доходил до модуля")
runner.stop()
mock.stop()
def scenario_no_poll():
print("== scenario: KE без poll — активация не наступила, тихая пауза")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--no-poll"], mock_port)
runner, timeout = run_runner(mock_port, listen, [], 5,
env_extra={"RUNNER_ACTIVATION_MS": "1500",
"RUNNER_QUIET_MS": "3000",
"RUNNER_KEEPALIVE_MS": "800"})
line = runner.wait_line("STATE recovering 5", 15)
check(line is not None, "recovering с ActivationTimeout")
time.sleep(1.0)
regs = [l for l in mock.all_lines() if l.startswith("REG")]
# Первый reg + возможно второй до таймаута активации; после — тишина.
check(len(regs) <= 3, f"нет спама local_reg (получено {len(regs)})")
runner.stop()
mock.stop()
def scenario_bad_ke():
print("== scenario: key_exchange ver=2 -> key_error")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--fail-first-ke"], mock_port)
runner, timeout = run_runner(mock_port, listen, [], 3)
line = runner.wait_line("STATE key_error 4", 10)
check(line is not None, "key_error с BadKeyExchange")
check(mock.wait_line("~-> 426", 10) is not None, "mock получил 426")
runner.stop()
mock.stop()
def scenario_chain_divergence_heals():
print("== scenario: входящая цепочка расходится на 1 сообщение и сходится")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--push-every", "1", "--garbage-pushes", "1"], mock_port)
runner, timeout = run_runner(mock_port, listen, [], 8,
env_extra={"RUNNER_KEEPALIVE_MS": "1000"})
check(runner.wait_line("STATE online", 10) is not None, "online")
check(runner.wait_line("STATE recovering 6", 15) is not None,
"recovering после урезанного push")
# CBC-состояние = последний шифроблок: следующий полный push сходится.
check(runner.wait_line("STATE online 0", 15) is not None,
"online восстановлен следующим push (цепочка сошлась)")
stats = runner.wait_line("STATS", timeout)
# Урезанный push + один residual (первый блок следующего) = 2 потери,
# далее цепочка сошлась (состояние CBC = последний шифроблок).
check(stats is not None and "pushes_bad=2" in stats,
f"ровно 2 потери: {stats}")
runner.stop()
mock.stop()
def scenario_outbound_desync_rekey():
print("== scenario: исходящий десинк -> модуль ре-кает на local_reg")
mock_port, listen = free_port(), free_port()
mock = run_mock(["--break-outbound", "1"], mock_port)
runner, timeout = run_runner(mock_port, listen, ["operation_mode"], 12,
env_extra={"RUNNER_KEEPALIVE_MS": "1000",
"RUNNER_SET_NAME": "fan_speed",
"RUNNER_SET_VALUE": "2",
"RUNNER_SET_AT": "6"})
check(runner.wait_line("STATE online", 10) is not None, "online")
check(mock.wait_line("~CMD skipped", 10) is not None,
"мок пропустил ответ (десинк)")
check(mock.wait_line("~CMD bad-sign", 15) is not None,
"подпись следующей команды не сошлась")
# Реальный модуль ре-кает на следующем local_reg (как в probe1-3);
# после re-key цепочки свежие — SET доходит.
check(mock.wait_line("~CMD SET fan_speed=2", 20) is not None,
"SET доставлена после re-key")
check(len([l for l in mock.all_lines() if l.startswith("KE ")]) >= 2,
"второй key exchange был")
runner.stop()
mock.stop()
def scenario_set_get():
print("== scenario: SET + подтверждение GET (эха нет)")
mock_port, listen = free_port(), free_port()
mock = run_mock([], mock_port)
env = {"RUNNER_SET_NAME": "fan_speed", "RUNNER_SET_VALUE": "3",
"RUNNER_SET_AT": "1"}
runner, timeout = run_runner(mock_port, listen, [], 5, env_extra=env)
line = runner.wait_line("SET_DONE fan_speed=3", 10)
check(line is not None, "SET отправлена")
check(mock.wait_line("CMD SET fan_speed=3", 10) is not None,
"mock применил SET")
runner.wait_line("DELETED", timeout)
runner.stop()
mock.stop()
def main():
scenarios = [scenario_normal, scenario_rekey, scenario_fail_push,
scenario_slot_503, scenario_no_poll, scenario_bad_ke,
scenario_chain_divergence_heals,
scenario_outbound_desync_rekey, scenario_set_get]
for sc in scenarios:
sc()
print()
if FAILURES:
print("ПРОВАЛЕНО:", len(FAILURES))
for f in FAILURES:
print(" -", f)
sys.exit(1)
print("ВСЕ СЦЕНАРИИ ПРОЙДЕНЫ")
if __name__ == "__main__":
main()