#!/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()