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