- docs/legacy/: рабочая раскладка изменённого форка (main.py + пакет aircon/); плоские дубликаты .py удалены, вложенный .git клона удалён (это изменённая копия, а не чистый клон), __pycache__ и .gitignore апстрима убраны. Локальный config_kata.json не версионируется (содержит lanip_key устройства). - docs/apk/: манифест APK; бинарники *.apk и icon.png не версионируются. - Обновлены пути в документации и tools/.
151 lines
13 KiB
Markdown
151 lines
13 KiB
Markdown
# Анализ 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. Требования к новой реализации, вытекающие из анализа
|
||
|
||
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 — устойчивая ошибка
|
||
и перепровижининг вручную (облако не дергать в рантайме).
|