Files
fgl-aircon/tools/probe_reference.py
Petr Polezhaev cd9ce65a11 refactor: legacy и apk перенесены в docs/, дедупликация исходников legacy
- docs/legacy/: рабочая раскладка изменённого форка (main.py + пакет
  aircon/); плоские дубликаты .py удалены, вложенный .git клона удалён
  (это изменённая копия, а не чистый клон), __pycache__ и .gitignore
  апстрима убраны. Локальный config_kata.json не версионируется
  (содержит lanip_key устройства).
- docs/apk/: манифест APK; бинарники *.apk и icon.png не версионируются.
- Обновлены пути в документации и tools/.
2026-09-17 19:48:55 +03:00

269 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 docs/legacy/config_kata.json monitor 60
python probe_reference.py docs/legacy/config_kata.json get operation_mode fan_speed
python probe_reference.py docs/legacy/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()