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:
83
tools/probe_mdns.py
Normal file
83
tools/probe_mdns.py
Normal 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 = "AC000W002879281"
|
||||
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
268
tools/probe_reference.py
Normal 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()
|
||||
Reference in New Issue
Block a user