Files
fgl-aircon/tests/ayla/mock_ac.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

421 lines
18 KiB
Python
Raw Permalink 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
"""Мок-модуль кондиционера (сторона устройства) для интеграционных тестов
сессии. stdlib-only: AES-256 реализован на чистом python (объёмы крошечные).
Сценарные флаги:
--503 всегда отвечать 503 на local_reg (нет слотов)
--no-poll key exchange без опроса commands.json (зависание)
--rekey-every N ре-кей на каждый N-й local_reg (N=1 — каждый)
--stale-gap S ре-кей, если зазор между local_reg >= S секунд
(эмуляция «вернувшегося» приложения; по умолчанию 44)
--garbage-pushes N первые N push с отрезанным блоком шифротекста
(входящая цепочка расходится на 1 сообщение)
--break-outbound N N раз «не заметить» ответ commands.json (исходящий
десинк): затем подпись наших команд не сойдётся —
мок, как реальный модуль, ре-кает на следующем local_reg
--fail-pushes N первые N push'ей с испорченной подписью
--push-every S спонтанный push свойства tick каждые S секунд
--fail-first-ke первый key_exchange с ver=2 (ожидаем 426)
Вывод (stdout, строки):
REG <first|put> notify=0|1
KE <random1>
CMD <method> <resource|name> [value]
PUSH <name> <value>
DELETE
"""
import argparse
import base64
import hashlib
import hmac
import http.server
import json
import random
import socket
import string
import sys
import threading
import time
# ---------------------------------------------------------------------------
# Чистый python AES-256 (encrypt/decrypt block), CBC поверх.
# ---------------------------------------------------------------------------
_SBOX = [
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16]
_RCON = [0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36,0x6c,0xd8,0xab,0x4d]
_INV_SBOX = [0]*256
for _i, _b in enumerate(_SBOX):
_INV_SBOX[_b] = _i
def _xtime(a):
a <<= 1
if a & 0x100:
a = (a ^ 0x1b) & 0xff
return a
def _expand_key(key): # 32 байта -> 60 слов по 4 байта (flat список)
w = list(key)
for i in range(32, 240, 4):
t = w[i-4:i]
if i % 32 == 0:
t = t[1:] + t[:1]
t = [_SBOX[b] for b in t]
t[0] ^= _RCON[i//32 - 1]
elif i % 32 == 16:
t = [_SBOX[b] for b in t]
w += [w[i-32+j] ^ t[j] for j in range(4)]
return w
def _aes_encrypt_block(w, block):
s = list(block)
def add_round_key(r):
for i in range(16):
s[i] ^= w[r*16 + i]
def sub_shift():
# SubBytes + ShiftRows (строка r — байты r, r+4, r+8, r+12 — влево на r)
t = [_SBOX[b] for b in s]
out = [0]*16
for r in range(4):
for c in range(4):
out[r + 4*c] = t[r + 4*((c + r) % 4)]
for i in range(16):
s[i] = out[i]
def mix():
t = [0]*16
for c in range(4):
col = s[c*4:c*4+4]
t[c*4+0] = _xtime(col[0]) ^ _xtime(col[1]) ^ col[1] ^ col[2] ^ col[3]
t[c*4+1] = col[0] ^ _xtime(col[1]) ^ _xtime(col[2]) ^ col[2] ^ col[3]
t[c*4+2] = col[0] ^ col[1] ^ _xtime(col[2]) ^ _xtime(col[3]) ^ col[3]
t[c*4+3] = _xtime(col[0]) ^ col[0] ^ col[1] ^ col[2] ^ _xtime(col[3])
for i in range(16):
s[i] = t[i]
add_round_key(0)
for rnd in range(1, 14):
sub_shift(); mix(); add_round_key(rnd)
sub_shift(); add_round_key(14)
return bytes(s)
def _aes_decrypt_block(w, block):
inv_sbox = _INV_SBOX
def inv_sub_shift(s):
t = [inv_sbox[b] for b in s]
out = [0]*16
for r in range(4):
for c in range(4):
out[r + 4*c] = t[r + 4*((c - r) % 4)] # инверсия сдвига влево на r
return out
def inv_mix(s):
def mul(a, b):
p = 0
for _ in range(8):
if b & 1:
p ^= a
hi = a & 0x80
a = (a << 1) & 0xff
if hi:
a ^= 0x1b
b >>= 1
return p
t = [0]*16
for c in range(4):
col = s[c*4:c*4+4]
t[c*4+0] = mul(col[0],14) ^ mul(col[1],11) ^ mul(col[2],13) ^ mul(col[3],9)
t[c*4+1] = mul(col[0],9) ^ mul(col[1],14) ^ mul(col[2],11) ^ mul(col[3],13)
t[c*4+2] = mul(col[0],13) ^ mul(col[1],9) ^ mul(col[2],14) ^ mul(col[3],11)
t[c*4+3] = mul(col[0],11) ^ mul(col[1],13) ^ mul(col[2],9) ^ mul(col[3],14)
return t
s = list(block)
def add_round_key(r):
for i in range(16):
s[i] ^= w[r*16 + i]
add_round_key(14)
for rnd in range(13, 0, -1):
s = inv_sub_shift(s) # InvShiftRows + InvSubBytes (коммутируют)
add_round_key(rnd)
s = inv_mix(s)
s = inv_sub_shift(s)
add_round_key(0)
return bytes(s)
class PyAes:
def __init__(self, key):
self.w = _expand_key(key)
def cbc_encrypt(self, iv, data):
out = b""
prev = iv
for i in range(0, len(data), 16):
blk = data[i:i+16]
blk = bytes(a ^ b for a, b in zip(blk, prev))
prev = _aes_encrypt_block(self.w, blk)
out += prev
return out, prev
def cbc_decrypt(self, iv, data):
out = b""
prev = iv
for i in range(0, len(data), 16):
blk = data[i:i+16]
dec = _aes_decrypt_block(self.w, blk)
out += bytes(a ^ b for a, b in zip(dec, prev))
prev = blk
return out, prev
# ---------------------------------------------------------------------------
class MockCrypto:
"""Ключи одной стороны мока: dev (исходящие push) и app (входящие команды)."""
def __init__(self, lanip_key, rnd1, rnd2, t1, t2):
k = lanip_key.encode()
b1, b2 = rnd1.encode(), rnd2.encode()
s1, s2 = str(t1).encode(), str(t2).encode()
def m(msg, suf):
msg = msg + bytes([suf])
return hmac.digest(k, hmac.digest(k, msg, "sha256") + msg, "sha256")
A, D = b1 + b2 + s1 + s2, b2 + b1 + s2 + s1
self.dev_sign, self.dev_aes = m(D, 0x30), PyAes(m(D, 0x31))
self.app_sign, self.app_aes = m(A, 0x30), PyAes(m(A, 0x31))
self.dev_iv, self.app_iv = m(D, 0x32)[:16], m(A, 0x32)[:16]
def pack_push(self, seq, data_json):
plain = json.dumps({"seq_no": seq, "data": data_json},
separators=(",", ":")).encode()
sign = base64.b64encode(hmac.digest(self.dev_sign, plain, "sha256")).decode()
n = ((len(plain) + 1 + 15) // 16) * 16
ct, self.dev_iv = self.dev_aes.cbc_encrypt(self.dev_iv, plain.ljust(n, b"\x00"))
enc = base64.b64encode(ct).decode()
return json.dumps({"enc": enc, "sign": sign}, separators=(",", ":"))
def unpack_command(self, body):
d = json.loads(body)
pt, self.app_iv = self.app_aes.cbc_decrypt(
self.app_iv, base64.b64decode(d["enc"]))
pt = pt.rstrip(b"\x00")
# Подпись проверяется ДО разбора JSON (мусор не парсим).
ok = base64.b64encode(hmac.digest(self.app_sign, pt, "sha256")).decode() == d["sign"]
if not ok:
return False, None
return True, json.loads(pt.decode())
# ---------------------------------------------------------------------------
class Mock:
def __init__(self, args):
self.args = args
self.lock = threading.Lock()
self.props = {"operation_mode": 6, "fan_speed": 4, "tick": 0}
self.crypto = None
self.app_addr = None # (ip, port) приложения
self.push_seq = 0
self.reg_count = 0
self.last_reg_time = None
self.fail_pushes = args.fail_pushes
self.garbage_pushes = args.garbage_pushes
self.miss_response = args.break_outbound
self.stop = threading.Event()
# ---------- исходящие к приложению ----------
def http_call(self, method, path, body=b"", timeout=5):
ip, port = self.app_addr
c = socket.create_connection((ip, port), timeout=timeout)
req = (f"{method} {path} HTTP/1.1\r\nHost: {ip}\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n").encode() + body
c.sendall(req)
raw = b""
while True:
chunk = c.recv(4096)
if not chunk:
break
raw += chunk
c.close()
head, _, resp_body = raw.partition(b"\r\n\r\n")
status = int(head.split(b" ")[1])
return status, resp_body
def do_key_exchange(self):
rnd1 = "".join(random.choice(string.ascii_letters + string.digits + "+/")
for _ in range(16))
t1 = int(time.monotonic_ns() // 1000)
ver = 2 if (self.args.fail_first_ke and self.reg_count == 1) else 1
body = json.dumps({"key_exchange": {
"ver": ver, "proto": 1, "key_id": self.args.key_id,
"random_1": rnd1, "time_1": t1, "sec": ""}},
separators=(",", ":")).encode()
status, resp = self.http_call("POST", "/local_lan/key_exchange.json", body)
print(f"KE {rnd1} -> {status}", flush=True)
if status != 200:
return False
d = json.loads(resp)
self.crypto = MockCrypto(self.args.lanip_key, rnd1, d["random_2"],
t1, d["time_2"])
return True
def poll_commands(self):
"""Опрашивает commands.json, пока 206; исполняет команды."""
while True:
status, body = self.http_call("GET", "/local_lan/commands.json")
if status != 200 and status != 206:
print(f"POLL -> {status}", flush=True)
return
if not self.crypto:
return
if self.miss_response > 0:
# «Модуль не получил/не расшифровал ответ»: цепочка приложения
# ушла, у мока нет — исходящий десинк.
self.miss_response -= 1
print("CMD skipped (outbound desync)", flush=True)
return
ok, payload = self.crypto.unpack_command(body)
if not ok:
print("CMD bad-sign", flush=True)
# Реальный модуль: на следующем local_reg — key exchange.
self.crypto = None
return
data = payload.get("data", {})
cmds = data.get("cmds", [])
props = data.get("properties", [])
if cmds:
for c in cmds:
cmd = c.get("cmd", {})
if cmd.get("method") == "DELETE":
print("DELETE", flush=True)
return
res = cmd.get("resource", "")
name = res.split("name=")[-1]
cid = cmd.get("cmd_id", -1)
print(f"CMD GET {name} cid={cid}", flush=True)
self.push_datapoint(name, cid=cid)
elif props:
for p in props:
pr = p.get("property", {})
self.props[pr.get("name", "?")] = pr.get("value")
print(f"CMD SET {pr.get('name')}={pr.get('value')}", flush=True)
elif not data:
return
if status == 200:
return
def push_datapoint(self, name, cid=-1, corrupt=False, garbage=False):
self.push_seq += 1
body = self.crypto.pack_push(self.push_seq - 1,
{"name": name, "value": self.props.get(name, 0)})
if garbage:
# РЕАЛЬНЫЙ десинк CBC: отрезать последний блок шифротекста —
# цепочка мока ушла на блок дальше, приложение отстанет.
d = json.loads(body)
ct = base64.b64decode(d["enc"])
d["enc"] = base64.b64encode(ct[:-16]).decode()
body = json.dumps(d, separators=(",", ":"))
if corrupt:
d = json.loads(body)
s = d["sign"]
d["sign"] = ("A" if s[-2] != "A" else "B") + s[1:]
body = json.dumps(d, separators=(",", ":"))
path = "/local_lan/property/datapoint.json"
if cid >= 0:
path += f"?cmd_id={cid}&status=200"
status, _ = self.http_call("POST", path, body.encode())
print(f"PUSH {name} -> {status}{' CORRUPT' if corrupt else ''}", flush=True)
# ---------- входящие local_reg ----------
def handle_local_reg(self, body):
self.reg_count += 1
d = json.loads(body)["local_reg"]
notify = d.get("notify", 0)
self.app_addr_json = d
self.app_addr = (d["ip"], d["port"])
print(f"REG {'first' if self.reg_count == 1 else 'put'} notify={notify}",
flush=True)
now = time.monotonic()
stale = (self.last_reg_time is not None and
now - self.last_reg_time >= self.args.stale_gap)
self.last_reg_time = now
if self.args.http503:
return 503
need_ke = self.crypto is None or stale or (
self.args.rekey_every and self.reg_count % self.args.rekey_every == 0)
if need_ke and not self.do_key_exchange():
return 202
if not self.args.no_poll:
self.poll_commands()
return 202
def spontaneous_loop(self):
while not self.stop.wait(self.args.push_every or 10):
if self.crypto and not self.args.no_poll:
with self.lock:
self.props["tick"] += 1
corrupt = self.fail_pushes > 0
if corrupt:
self.fail_pushes -= 1
garbage = self.garbage_pushes > 0
if garbage:
self.garbage_pushes -= 1
self.push_datapoint("tick", corrupt=corrupt, garbage=garbage)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, required=True)
ap.add_argument("--lanip-key", required=True)
ap.add_argument("--key-id", type=int, required=True)
ap.add_argument("--503", dest="http503", action="store_true")
ap.add_argument("--no-poll", action="store_true")
ap.add_argument("--rekey-every", type=int, default=0)
ap.add_argument("--fail-pushes", type=int, default=0)
ap.add_argument("--garbage-pushes", type=int, default=0)
ap.add_argument("--break-outbound", type=int, default=0)
ap.add_argument("--stale-gap", type=float, default=44.0)
ap.add_argument("--push-every", type=float, default=0)
ap.add_argument("--fail-first-ke", action="store_true")
args = ap.parse_args()
mock = Mock(args)
lock = mock.lock
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(n)
if self.path.startswith("/local_reg.json"):
with lock:
code = mock.handle_local_reg(body)
self.send_response(code)
self.send_header("Content-Length", "0")
self.end_headers()
return
self.send_response(404)
self.send_header("Content-Length", "0")
self.end_headers()
def do_PUT(self):
self.do_POST()
srv = http.server.ThreadingHTTPServer(("127.0.0.1", args.port), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
if args.push_every:
threading.Thread(target=mock.spontaneous_loop, daemon=True).start()
print("READY", flush=True)
try:
while True:
time.sleep(0.5)
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()