Files
fgl-aircon/docs/LEGACY_ANALYSIS.md
Petr Polezhaev e74f3dc67a 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 14:56:47 +03:00

152 lines
13 KiB
Markdown
Raw Permalink 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.
# Анализ 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 — устойчивая ошибка
и перепровижининг вручную (облако не дергать в рантайме).