docs: реконструкция LAN-протокола FGLair, анализ legacy, планы fglair-core/HA/ESPHome

- PROTOCOL.md: полная спецификация (KDF, envelope/CBC-цепочка, local_reg,
  key exchange, commands.json 206/200, datapoint push, тайминги, таблицы
  свойств шаблонов A/B/F, облачный provisioning). Факты из APK помечены
  [APK], проверенные живыми экспериментами на AP-WC1E — [ПРОВЕРЕНО НА
  ПРИБОРЕ]: mDNS только :10276; лимит 2 LAN-сессии (3-я -> 503);
  принудительный re-key при возрасте сессии >= ~44с (единственный механизм
  самолечения десинхрона — 400/401 модуль игнорирует); записи не
  эхируются; delete_session освобождает слот.
- LEGACY_ANALYSIS.md: причины рассинхрона (keep-alive 1200с вместо 10-15с
  + seq_no-фильтр) и перегрузки модуля; требования к новой реализации.
- PLAN_CORE_LIBRARY.md: план C++20-библиотеки fglair-core (Linux + ESP-IDF),
  ключ lanip_key считается статичным, ротация — только ошибка + ручной
  перепровижининг.
- PLAN_HOME_ASSISTANT.md: pyfglair (cffi wheel) + custom component,
  облачный provisioning только в config flow, ключ виден в диагностике
  для копирования в ESPHome.
- PLAN_ESPHOME.md: external component, только ESP-IDF framework.
- tools/probe_reference.py: эталонный клиент протокола (проверен на
  приборе end-to-end); tools/probe_mdns.py — mDNS-проба.
- legacy/: снимок скрипта (апстрим gyro-labs/AirCon, вложенный клон не
  версионируется); apk/*.apk исключены из версионирования.
This commit is contained in:
2026-09-17 19:44:49 +03:00
commit 024b290b89
25 changed files with 3904 additions and 0 deletions

83
tools/probe_mdns.py Normal file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Пассивная mDNS-проба модуля кондиционера: A-запрос <DSN>.local
на 224.0.0.251:5353 и :10276 (нестандартный порт Ayla из APK)."""
import socket, struct, sys, time
DSN = "AC000W00REDACTED"
HOST = DSN + ".local"
def qname(name):
out = b""
for lbl in name.split("."):
out += bytes([len(lbl)]) + lbl.encode()
return out + b"\x00"
def query_packet(txid):
# standard query, RD=1, one question: A, class IN
header = struct.pack(">HHHHHH", txid, 0x0100, 1, 0, 0, 0)
return header + qname(HOST) + struct.pack(">HH", 1, 1)
def parse_name(buf, off):
parts = []
while True:
l = buf[off]
if l == 0:
off += 1
break
if l & 0xC0 == 0xC0:
ptr = struct.unpack(">H", buf[off:off+2])[0] & 0x3FFF
parts.append(parse_name(buf, ptr)[0])
off += 2
break
parts.append(buf[off+1:off+1+l].decode("latin1"))
off += 1 + l
return ".".join(parts), off
def parse_answers(buf):
try:
qd = struct.unpack(">H", buf[4:6])[0]
an = struct.unpack(">H", buf[6:8])[0]
off = 12
for _ in range(qd):
_, off = parse_name(buf, off)
off += 4
found = []
for _ in range(an):
name, off = parse_name(buf, off)
rtype, rclass, ttl, rdlen = struct.unpack(">HHIH", buf[off:off+10])
off += 10
rdata = buf[off:off+rdlen]
if rtype == 1 and rdlen == 4:
ip = ".".join(str(b) for b in rdata)
found.append((name, ip, rclass, ttl))
off += rdlen
return found
except Exception as e:
return [("parse-error", str(e), 0, 0)]
def probe(port, txid, wait=4.0):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("", 0))
pkt = query_packet(txid)
for _ in range(3):
s.sendto(pkt, ("224.0.0.251", port))
time.sleep(0.5)
s.settimeout(wait)
try:
while True:
data, addr = s.recvfrom(4096)
print(f" [:{port}] from {addr}: {len(data)} bytes")
for rec in parse_answers(data):
print(f" {rec}")
except socket.timeout:
pass
finally:
s.close()
if __name__ == "__main__":
print("mDNS A?", HOST)
print("-- querying :5353")
probe(5353, 0x1234)
print("-- querying :10276")
probe(10276, 0x1235)

268
tools/probe_reference.py Normal file
View File

@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Эталонный клиент LAN-протокола FGLair (сторона «приложения»).
Проверен на реальном модуле AP-WC1E (fw 2.6.17-fgl2). Соответствует
docs/PROTOCOL.md, включая поведение, подтверждённое живыми тестами:
- keep-alive каждые 15 c (модуль сам инициирует re-key при возрасте
сессии >= ~44 c — это штатная ротация, обрабатывается прозрачно);
- 206/200 в commands.json, NUL-паддинг (Java-вариант);
- 401 при ошибке расшифровки, 412 при несовпадении key_id;
- записи не эхируются: оптимистичное обновление + GET-подтверждение;
- delete_session при выходе (освобождает один из 2 слотов модуля).
Использование:
python probe_reference.py config_kata.json monitor 60
python probe_reference.py config_kata.json get operation_mode fan_speed
python probe_reference.py config_kata.json set fan_speed 3
Зависимости: pycryptodome."""
import base64, hmac, json, random, socket, string, sys, threading, time
import http.client
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from Crypto.Cipher import AES
T0 = time.time()
log = lambda *a: print(f"+{time.time()-T0:7.1f}s", *a, flush=True)
def rand_token(n=16):
return "".join(random.choice(string.ascii_letters + string.digits) for _ in range(n))
class Crypto:
"""Ключи/цепочки по PROTOCOL.md §3. ВНИМАНИЕ: CBC-состояние непрерывно
в рамках сессии (одно сообщение — следующее продолжает цепочку)."""
def __init__(self, lanip_key, rnd1, rnd2, t1, t2):
k = lanip_key.encode()
b1, b2, s1, s2 = rnd1.encode(), rnd2.encode(), 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.app_sign, self.dev_sign = m(A, 0x30), m(D, 0x30)
self._e = AES.new(m(A, 0x31), AES.MODE_CBC, m(A, 0x32)[:16])
self._d = AES.new(m(D, 0x31), AES.MODE_CBC, m(D, 0x32)[:16])
self.seq = 0
def enc_sign(self, data) -> bytes:
self.seq += 1
raw = json.dumps({"seq_no": self.seq - 1, "data": data},
separators=(",", ":")).encode()
n = ((len(raw) + 1 + 15) // 16) * 16 # >=1 NUL, кратно 16
sign = base64.b64encode(hmac.digest(self.app_sign, raw, "sha256")).decode()
enc = base64.b64encode(self._e.encrypt(raw.ljust(n, b"\x00"))).decode()
return json.dumps({"enc": enc, "sign": sign}, separators=(",", ":")).encode()
def decrypt_validate(self, body: dict):
ptb = self._d.decrypt(base64.b64decode(body["enc"])).rstrip(b"\x00")
ok = base64.b64encode(hmac.digest(self.dev_sign, ptb, "sha256")).decode() == body.get("sign")
return ok, ptb
class ReferenceClient:
def __init__(self, cfg_path, port=10275, keepalive=15.0):
cfg = json.load(open(cfg_path))
self.ip, self.dsn = cfg["ip_address"], cfg["dsn"]
self.key, self.key_id = cfg["lanip_key"], cfg["lanip_key_id"]
self.port, self.keepalive = port, keepalive
self.crypto = None
self.queue = [] # [(payload, note)]
self.cmd_id = 0
self.props = {} # кэш значений
self.pushes = 0
self.online = threading.Event()
self.lock = threading.Lock()
self._ka_stop = threading.Event()
# ---------------- HTTP-сервер (входящие от модуля) ----------------
def _handler(self):
cli = self
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a): pass
def _body(self):
n = int(self.headers.get("Content-Length") or 0)
return self.rfile.read(n) if n else b""
def _send(self, code, body=b""):
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body: self.wfile.write(body)
def do_POST(self):
body, path = self._body(), self.path.split("?")[0]
if path == "/local_lan/key_exchange.json":
ke = json.loads(body)["key_exchange"]
if ke.get("ver") != 1 or ke.get("proto") != 1:
self._send(426, b'{"error":"Unsupported crypto version"}'); return
if ke.get("key_id") != cli.key_id:
log(f"KEY ROTATED: {ke.get('key_id')} != {cli.key_id} -> 412")
self._send(412, b'{"error":"Keys do not match"}'); return
rnd2, t2 = rand_token(), time.monotonic_ns()
cli.crypto = Crypto(cli.key, ke["random_1"], rnd2, ke["time_1"], t2)
log(f"KEY_EXCHANGE (re-key ok, random_1={ke['random_1']!r})")
self._send(200, json.dumps(
{"random_2": rnd2, "time_2": t2}).encode()); return
if path.endswith("/local_lan/property/datapoint.json"):
if cli.crypto is None:
self._send(401, b'{"error":"Decryption failed"}'); return
ok, ptb = cli.crypto.decrypt_validate(json.loads(body))
if not ok:
log("DATAPPOINT: подпись/расшифровка НЕ сошлись -> 401")
self._send(401, b'{"error":"Decryption failed"}'); return
cli.pushes += 1
try:
d = json.loads(ptb)["data"]
with cli.lock:
cli.props[d["name"]] = d.get("value")
log(f"PUSH {d['name']} = {d.get('value')}"
f" (query={self.path.split('?', 1)[-1] or '-'})")
except Exception as e:
log("PUSH parse error:", e)
self._send(200); return
if path.endswith("/ack.json"):
log("ACK", body[:120]); self._send(200); return
self._send(404)
def do_GET(self):
if self.path.split("?")[0] == "/local_lan/commands.json":
if cli.crypto is None:
self._send(401, b'{"error":"Decryption failed"}'); return
with cli.lock:
payload, note = cli.queue.pop(0) if cli.queue else ({}, "empty")
rest = len(cli.queue)
code = 206 if rest else 200
log(f"COMMANDS -> {note} [{code}]")
self._send(code, cli.crypto.enc_sign(payload)); return
self._send(404)
return H
# ---------------- исходящие ----------------
def local_reg(self, notify, first=False):
method = "POST" if first else "PUT"
url = f"/local_reg.json" + (f"?dsn={self.dsn}" if first else "")
body = json.dumps({"local_reg": {"ip": self._my_ip(), "notify": 1 if notify else 0,
"port": self.port, "uri": "/local_lan"}})
c = http.client.HTTPConnection(self.ip, timeout=10)
try:
c.request(method, url, body=body, headers={
"Accept": "application/json", "Connection": "keep-alive",
"Content-Type": "application/json", "Accept-Encoding": "gzip"})
r = c.getresponse(); r.read()
if r.status == 503:
log("local_reg -> 503: нет свободных слотов (заняты 2 сессии)")
return r.status
finally:
c.close()
@staticmethod
def _my_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("10.255.255.255", 1)); return s.getsockname()[0]
finally:
s.close()
def queue_get(self, prop):
with self.lock:
self.cmd_id += 1
cid = self.cmd_id
self.queue.append(({"cmds": [{"cmd": {
"method": "GET", "resource": "property.json?name=" + prop,
"uri": "/local_lan/property/datapoint.json", "data": "",
"cmd_id": cid}}]}, f"GET {prop}"))
def queue_set(self, prop, value):
with self.lock:
self.queue.append(({"properties": [{"property": {
"base_type": "integer", "name": prop, "value": value,
"id": rand_token(8)}}]}, f"SET {prop}={value}"))
self.props[prop] = value # оптимистично: эха нет
def queue_delete_session(self):
with self.lock:
self.queue.append(({"cmds": [{"cmd": {"cmd_id": 0, "method": "DELETE",
"resource": "local_reg.json", "data": "delete_session",
"uri": "/local_lan"}}]}, "DELETE session"))
# ---------------- жизненный цикл ----------------
def start(self, timeout=15):
srv = ThreadingHTTPServer(("0.0.0.0", self.port), self._handler())
srv.handle_error = lambda *a: None # RST от модуля — норма
threading.Thread(target=srv.serve_forever, daemon=True).start()
self._srv = srv
st = self.local_reg(notify=0, first=True)
t0 = time.time()
while time.time() - t0 < timeout:
if self.crypto and self.pushes >= 0 and self._activated:
break
time.sleep(0.05)
if not self._activated:
raise RuntimeError("сессия не активировалась (нет опроса commands.json после KE)")
threading.Thread(target=self._keepalive_loop, daemon=True).start()
self.online.set()
_activated = False
def notify_activation(self):
self._activated = True
def _keepalive_loop(self):
while not self._ka_stop.wait(self.keepalive):
try:
notify = bool(self.queue)
self.local_reg(notify=notify)
except Exception as e:
log("keep-alive error:", e)
def stop(self):
self._ka_stop.set()
try:
self.queue_delete_session()
self.local_reg(notify=True)
time.sleep(2)
except Exception:
pass
self._srv.shutdown()
# ------------------------------------------------------------------
def patch_activation(cli):
"""Активация = первый GET commands.json после KE."""
orig = cli._handler
def wrapper():
H = orig()
class H2(H):
def do_GET(self):
cli.notify_activation()
H.do_GET(self)
return H2
cli._handler = wrapper
def main():
if len(sys.argv) < 3:
print(__doc__); return
cfg, cmd = sys.argv[1], sys.argv[2]
cli = ReferenceClient(cfg)
patch_activation(cli)
cli.start()
log("сессия установлена")
try:
if cmd == "get":
for p in sys.argv[3:]:
cli.queue_get(p)
cli.local_reg(notify=True)
time.sleep(8)
elif cmd == "set":
prop, val = sys.argv[3], int(sys.argv[4])
cli.queue_set(prop, val)
time.sleep(0.3)
cli.local_reg(notify=True)
time.sleep(3)
cli.queue_get(prop) # GET-подтверждение (эха нет)
cli.local_reg(notify=True)
time.sleep(5)
elif cmd == "monitor":
for p in ("operation_mode", "fan_speed", "adjust_temperature",
"display_temperature", "wifi_led_enable"):
cli.queue_get(p)
cli.local_reg(notify=True)
time.sleep(int(sys.argv[3]) if len(sys.argv) > 3 else 60)
log("кэш свойств:", json.dumps(cli.props, ensure_ascii=False))
finally:
cli.stop()
log("сессия закрыта (delete_session)")
if __name__ == "__main__":
main()