- 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()
|