- docs/legacy/: рабочая раскладка изменённого форка (main.py + пакет aircon/); плоские дубликаты .py удалены, вложенный .git клона удалён (это изменённая копия, а не чистый клон), __pycache__ и .gitignore апстрима убраны. Локальный config_kata.json не версионируется (содержит lanip_key устройства). - docs/apk/: манифест APK; бинарники *.apk и icon.png не версионируются. - Обновлены пути в документации и tools/.
13 KiB
Анализ 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).
Проверено на приборе: единственный механизм восстановления после расхождения
CBC-цепочек — принудительный re-key, который модуль делает при получении
local_reg для сессии старше ≈44 с. Ответы 400/401 модуль игнорирует.
Следствие для legacy: любая потерянная пара запрос-ответ/обрыв соединения →
обе стороны «глохнут» на срок до 20 минут (до следующего local_reg). Наблюдаемый
симптом «перестаёт понимать кондиционер» с самопроизвольным восстановлением —
именно это.
Дополнительно: длинные паузы между 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. Требования к новой реализации, вытекающие из анализа
- Keep-alive по APK-таймингам: 10–15 с. Это одновременно и период самолечения десинхрона (модуль сам сделает re-key на ≈44-й секунде).
- Воспроизводить Java-поведение в кодах ответов: 401 при ошибках расшифровки, 412 при несовпадении key_id, 206/200 в commands.json, NUL-паддинг.
- Начальная синхронизация: один пакет GET всех нужных свойств после key exchange; далее — push-driven. Периодический опрос — только как diagnosка с большим интервалом и по требованию.
- Записи не эхируются: оптимистичное обновление + при необходимости GET-подтверждение.
- Не проверять seq_no входящих сообщений.
- Debounce команд: копить 100–300 мс, отправлять одним пакетом; один local_reg notify=1 на пакет. Не более одного local_reg в ~1 с.
- Rate-limit очереди, backoff при ошибках (включая режим «KE без poll» — пауза, а не долбёжка), корректное завершение (delete_session) для освобождения слота.
- Считать lanip_key статичным: при несовпадении key_id — устойчивая ошибка и перепровижининг вручную (облако не дергать в рантайме).