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:
2026-09-17 19:44:49 +03:00
commit 157df4e795
26 changed files with 3916 additions and 0 deletions

73
legacy/config.py Normal file
View File

@@ -0,0 +1,73 @@
from Crypto.Cipher import AES
from dataclasses import dataclass
import hmac
import random
import string
import time
from .error import KeyIdReplaced
@dataclass
class LanConfig:
lanip_key: str
lanip_key_id: int
random_1: str
time_1: int
random_2: str
time_2: int
@dataclass
class Encryption:
sign_key: bytes
crypto_key: bytes
iv_seed: bytes
cipher: AES
def __init__(self, lanip_key: bytes, msg: bytes):
self.sign_key = self._build_key(lanip_key, msg + b'0')
self.crypto_key = self._build_key(lanip_key, msg + b'1')
self.iv_seed = self._build_key(lanip_key, msg + b'2')[:AES.block_size]
self.cipher = AES.new(self.crypto_key, AES.MODE_CBC, self.iv_seed)
@classmethod
def _build_key(cls, lanip_key: bytes, msg: bytes) -> bytes:
return cls.hmac_digest(lanip_key, cls.hmac_digest(lanip_key, msg) + msg)
@staticmethod
def hmac_digest(key: bytes, msg: bytes) -> bytes:
return hmac.digest(key, msg, 'sha256')
@dataclass
class Config:
_lan_config: LanConfig
app: Encryption
dev: Encryption
def __init__(self, lanip_key: str, lanip_key_id: int):
self._lan_config = LanConfig(lanip_key, lanip_key_id, '', 0, '', 0)
self._update_encryption()
def update(self, key: dict):
"""Updates the stored lan config, and encryption data."""
self._lan_config.random_1 = key['random_1']
self._lan_config.time_1 = key['time_1']
if key['key_id'] != self._lan_config.lanip_key_id:
raise KeyIdReplaced(
'The key_id has been replaced!!',
'Old ID was {}; new ID is {}.'.format(self._lan_config.lanip_key_id, key['key_id']))
self._lan_config.random_2 = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
self._lan_config.time_2 = time.monotonic_ns()
self._update_encryption()
return {'random_2': self._lan_config.random_2, 'time_2': self._lan_config.time_2}
def _update_encryption(self):
lanip_key = self._lan_config.lanip_key.encode('utf-8')
random_1 = self._lan_config.random_1.encode('utf-8')
random_2 = self._lan_config.random_2.encode('utf-8')
time_1 = str(self._lan_config.time_1).encode('utf-8')
time_2 = str(self._lan_config.time_2).encode('utf-8')
self.app = Encryption(lanip_key, random_1 + random_2 + time_1 + time_2)
self.dev = Encryption(lanip_key, random_2 + random_1 + time_2 + time_1)