Files
fgl-aircon/docs/LEGACY_ANALYSIS.md
Petr Polezhaev bfdf22ba64 core(M2): машина состояний сессии Ayla LAN + mock-модуль + интеграционные сценарии
- session.{hpp,cpp}: state machine (idle/registering/online/recovering/
  offline/key_error); httpd-обработчики key_exchange (200/426/412, re-key
  прозрачно), commands (одна команда, 206/200, envelope, глобальный seq_no),
  datapoint (unpack -> PropertyEvent / 401+тишина 50с для re-key-восстановления);
  сессионный поток: local_reg POST?dsn/PUT (local_ip_for), keep-alive, backoff
  x1.6->60с, 503->offline/NoSlot, activation-timeout->recovering, delete_session
  с ожиданием выдачи; очередь с coalescing + batch; телеметрия; колбэки из
  двух потоков с задокументированным контрактом; буферы datapoint-пути в Impl.
- platform: local_ip_for (UDP-connect) posix+esp-idf; стек httpd 24576
  (переполнение 16КБ поймано gdb на Release).
- mock_ac.py: мок-модуль, stdlib-only чистый python AES-256 (свёрстан с
  pycryptodome); сценарии: 503, no-poll, rekey-every, stale-gap (эмуляция
  'вернувшегося' приложения), fail-pushes (битая подпись), garbage-pushes
  (обрыв блока), break-outbound (исходящий десинк -> модуль ре-кает на
  local_reg, как probe1-3), push-every, fail-first-ke.
- session_runner + test_session_mock.py: 9 сценариев через ctest, включая
  самосинхронизацию CBC и восстановление после исходящего десинка.
- Прибор AP-WC1E: активация <=1с; re-key семантика ИСПРАВЛЕНА по живым
  тестам: re-key при зазоре local_reg >= ~44-50с (не по возрасту сессии!);
  при честном keep-alive 15с сессия стабильна без re-key; PROTOCOL/LEGACY/
  PLAN обновлены; восстановление = тишина >порога + возврат.
- CI: 7/7 x3 (gcc-Rel, gcc-ASan/UBSan, clang); ESP-IDF esp32 build complete.
Ревью под-агентом: 2 круга (стек httpd, залипание состояний, dangling cfg,
физика десинка) — APPROVED.
2026-09-27 10:53:28 +03:00

13 KiB
Raw Blame History

Анализ legacy-скрипта (docs/legacy/): соответствие протоколу и найденные проблемы

Скрипт — форк проекта hisense_ac (deiger), адаптированный под FGLair. Общая логика протокола воспроизведена верно, но есть критические расхождения с APK и поведением модуля (проверено живыми экспериментами на приборе), которые объясняют оба наблюдаемых симптома: «рассинхронизацию ключей» и перегрузку модуля.

Живые проверки прибора (AP-WC1E) показали: модуль игнорирует 400/401 на свои POST, восстанавливается только принудительным re-key по local_reg (порог возраста сессии ≈44 с); максимум 2 LAN-сессии; записи не эхируются. Подробности — PROTOCOL.md §4.4, §5.3, §6.3, §10.

1. Что воспроизведено корректно

Часть Файл Оценка
KDF ключей (app/dev, suffix 0/1/2) config.py Точно совпадает с AylaEncryption.generateSessionKeys; подтверждено на приборе
AES-256-CBC, zero-pad, HMAC-sign query_handlers.py Совпадает; CBC-цепочка подтверждена на приборе (несколько последовательных сообщений)
CBC-цепочка в рамках сессии config.py (один объект cipher) Совпадает с Java (persist state)
Роуты /local_lan/* main.py Совпадают с AylaHttpServer.addMappings
Формат commands.json (по одной команде, seq_no, {} при пустой очереди) query_handlers.py Совпадает. Но: нет 206/200-различения (см. 2.6)
Формат datapoint push и GET-ответов query_handlers.py Совпадает
local_reg body/методы POST/PUT notifier.py Совпадает (формат некритичен — проверено)
Оптимистичное обновление при записи aircon.py (property_updater) Верно: записи не эхируются (проверено на приборе)
Облачный discovery (sign_in/devices/lan.json, секреты) discovery.py, app_mappings.py Совпадает (проверено: EU secret = base64url из SECRET_MAP)
Таблица свойств FGL (шаблон A) properties.py Частично; много свойств отсутствует (op_status, error_code, powerful_mode, min_heat, coil_dry, device_capabilities, …) — см. PROTOCOL.md §8.2

2. Расхождения с APK/прибором (= баги)

2.1. [ГЛАВНАЯ ПРИЧИНА «РАССИНХРОНИЗАЦИИ»] Keep-alive 1200 с вместо 10–15 с

notifier.py:_KEEP_ALIVE_INTERVAL = 1200.0. APK: 10 с (или lan.json:keepAlive/3). Проверено на приборе: модуль игнорирует 400/401 на свои POST; переkey происходит при local_reg после зазора ≥ ~44–50 с от предыдущего (при keep-alive 10–15 с re-key вообще не происходит). Следствие для legacy: любая потерянная пара запрос-ответ → обе стороны «глохнут» до следующего local_reg (до 20 минут), который завершится re-key — наблюдаемый симптом «перестаёт понимать кондиционер, потом сам чинится» — именно это. Корректная стратегия для новой реализации: при ошибке расшифровки — пауза ~50 с, затем local_reg (re-key гарантирован).

Дополнительно: длинные паузы между local_reg держат сессию «полуживой» (модуль не видит keep-alive, но слот может удерживаться), и конфликт за 2 доступных слота с телефоном/вторым клиентом становится вероятнее.

2.2. [ТЕОРЕТИЧЕСКОЕ] Неверная обработка смены lanip_key_id

config.py:update бросает KeyIdReplaced → key_exchange_handler отвечает 404 Not Found вместо 412 Precondition Failed (APK) и никогда не перечитывает lan.json. За 5 лет эксплуатации ротация ключа не наблюдалась ни разу (ключ, по-видимому, статичен и зашит в модуль), так что на практике благополучен — но код вводит в заблуждение и чинится тривиально.

2.3. Drop легитимных обновлений по seq_no

aircon.py:is_update_valid отбрасывает обновления с seq_no меньше последнего (кроме 0). На приборе: seq_no модуля сбрасывается в 0 при каждом re-key и растёт внутри сессии. При штатных (для legacy — раз в 1200 с) re-key'ах фильтр пропускает только первый push сессии (seq 0) и отбрасывает все последующие (1, 2, … < накопленного максимума). APK не проверяет seq_no входящих вообще. Итог: пропущенные обновления состояния после каждого re-key — второй вклад в «скрипт не видит изменений».

2.4. 400 вместо 401 при ошибке расшифровки

query_handlers.property_update_handler возвращает 400, APK — 401. На приборе модуль игнорирует оба кода, так что это НЕ причина рассинхрона (первоначальная гипотеза опровергнута экспериментом). Исправить стоит для APK-совместимости, потому что код ответа — часть интерфейса.

2.5. Мелочи шифрования

  • Паддинг: скрипт НЕ добавляет обязательный завершающий NUL (Java добавляет len+1). На приборе работает оба варианта; для совместимости повторить Java.
  • t_fan_speed/t_control_value (AcDevice/Hisense-свойства) для FGLair-устройств не используются — кодовая basePath висит мёртвым грузом.

2.6. Отсутствие 206-ответов

command_handler всегда отвечает 200. APK отвечает 206, пока очередь не пуста. Без 206 модуль вынужден либо перепрашивать local_reg, либо опрашивать вслепую — вероятный вклад в перегрузку.

2.7. Нет DELETE-команды сессии при завершении

Скрипт не отправляет delete_session — модуль держит полумёртвую сессию в одном из 2 слотов.

3. Причины перегрузки модуля (спам → модуль отключается от Wi-Fi)

3.1. Статусный цикл: 33 GET-команды каждые 600 с

main.py:query_status_device ставит в очередь по одной GET-команде на каждое свойство (поля dataclass) каждые 600 с, плюс ещё раз при старте. APK запрашивает все свойства один раз при установке сессии (fetchPropertiesLAN) и далее живёт на push-обновлениях; поллит отдельные свойства только после команд с побочными эффектами. Постоянный циклический опрос — лишние сотни HTTP-транзакций и AES-операций на приборе, у которого слабый CPU.

3.2. local_reg на каждую команду без debounce

Каждый queue_command → _queue_listener() → немедленный local_reg notify=1. Действие из HA (mode+temp+fan) = 3 команды = до 3 local_reg подряд. APK шлёт один local_reg на пакет команд (AylaLocalNetwork.performRequest).

3.3. Агрессивный цикл Notifier при непустой очереди

notifier.py:start: пока qsize > 1 — sleep всего 60 с и повторная отправка local_reg. Если модуль «застрял» (не забирает команды), очередь растёт (см. 3.1), local_reg продолжает долбить каждые 60 с + retry-логика tenacity (6 попыток, экспоненциально). Мёртвый цикл под нагрузкой. На приборе подтверждён паттерн: после серий неудачных попыток регистрации модуль может «зависать» в режиме «KE без активации» — долбить его повторными local_reg бесполезно, нужен backoff и пауза (PROTOCOL.md §4.4 п.6).

3.4. Странный старт

При старте: query_status_device немедленно (без начальной задержки) наполняет очередь 33 GET-командами, а Notifier.start в первой же итерации отправляет local_reg (таймер last_timestamp=0 срабатывает сразу). Возникает гонка: notify в первом POST/PUT зависит от того, успела ли очередь наполниться, и модуль сразу получает «тяжёлый» старт — массовая выдача 33 команд новой сессии. Правильная последовательность (APK): local_reg notify=0 → key exchange → один пакет GET-запросов → далее только push.

4. Прочие замечания

  • MQTT: подписка на $SYS/broker/log/M/subscribe/# — hack для перепосылки статуса новым подписчикам; в HA-интеграции не понадобится.
  • f_temp_in/t_power-мэппинги — код Hisense-ветки, для FGL не нужен.
  • Потокобезопасность: pycryptodome cipher используется из одного event-loop — ок, но при любом выносе в треды потребует сериализации (CBC-цепочка!).

5. Требования к новой реализации, вытекающие из анализа

  1. Keep-alive по APK-таймингам: 10–15 с. Это одновременно и период самолечения десинхрона (модуль сам сделает re-key на ≈44-й секунде).
  2. Воспроизводить Java-поведение в кодах ответов: 401 при ошибках расшифровки, 412 при несовпадении key_id, 206/200 в commands.json, NUL-паддинг.
  3. Начальная синхронизация: один пакет GET всех нужных свойств после key exchange; далее — push-driven. Периодический опрос — только как diagnosка с большим интервалом и по требованию.
  4. Записи не эхируются: оптимистичное обновление + при необходимости GET-подтверждение.
  5. Не проверять seq_no входящих сообщений.
  6. Debounce команд: копить 100–300 мс, отправлять одним пакетом; один local_reg notify=1 на пакет. Не более одного local_reg в ~1 с.
  7. Rate-limit очереди, backoff при ошибках (включая режим «KE без poll» — пауза, а не долбёжка), корректное завершение (delete_session) для освобождения слота.
  8. Считать lanip_key статичным: при несовпадении key_id — устойчивая ошибка и перепровижининг вручную (облако не дергать в рантайме).