- 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 исключены из версионирования.
269 lines
12 KiB
Python
269 lines
12 KiB
Python
#!/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()
|