Files
fgl-aircon/docs/PLAN_CORE_LIBRARY.md
Petr Polezhaev 024b290b89 docs: реконструкция LAN-протокола FGLair, анализ legacy, планы fglair-core/HA/ESPHome
- PROTOCOL.md: полная спецификация (KDF, envelope/CBC-цепочка, local_reg,
  key exchange, commands.json 206/200, datapoint push, тайминги, таблицы
  свойств шаблонов A/B/F, облачный provisioning). Факты из APK помечены
  [APK], проверенные живыми экспериментами на AP-WC1E — [ПРОВЕРЕНО НА
  ПРИБОРЕ]: mDNS только :10276; лимит 2 LAN-сессии (3-я -> 503);
  принудительный re-key при возрасте сессии >= ~44с (единственный механизм
  самолечения десинхрона — 400/401 модуль игнорирует); записи не
  эхируются; delete_session освобождает слот.
- LEGACY_ANALYSIS.md: причины рассинхрона (keep-alive 1200с вместо 10-15с
  + seq_no-фильтр) и перегрузки модуля; требования к новой реализации.
- PLAN_CORE_LIBRARY.md: план C++20-библиотеки fglair-core (Linux + ESP-IDF),
  ключ lanip_key считается статичным, ротация — только ошибка + ручной
  перепровижининг.
- PLAN_HOME_ASSISTANT.md: pyfglair (cffi wheel) + custom component,
  облачный provisioning только в config flow, ключ виден в диагностике
  для копирования в ESPHome.
- PLAN_ESPHOME.md: external component, только ESP-IDF framework.
- tools/probe_reference.py: эталонный клиент протокола (проверен на
  приборе end-to-end); tools/probe_mdns.py — mDNS-проба.
- legacy/: снимок скрипта (апстрим gyro-labs/AirCon, вложенный клон не
  версионируется); apk/*.apk исключены из версионирования.
2026-09-17 19:44:49 +03:00

274 lines
21 KiB
Markdown
Raw 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.
# План: кросс-платформенная библиотека `fglair-core` (C++20, Linux + ESP-IDF)
Целевая аудитория документа — агенты-реализаторы. Протокольные детали — в
`PROTOCOL.md` (ссылки вида «§N», факты помечены [ПРОВЕРЕНО НА ПРИБОРЕ]).
Причины проектных решений — `LEGACY_ANALYSIS.md`.
## 1. Цели и не-цели
Цели:
1. Реализовать LAN-протокол FGLair/Ayla (сторона «приложения») по спецификации
`PROTOCOL.md` с поведением, максимально близким к официальному APK и
подтверждённому живыми тестами прибора.
2. Портативность: сборка как C++20-библиотека (Linux, CMake) и как компонент
ESP-IDF (esp32/esp32s3/esp32c3 — везде ESP-IDF, Arduino-фреймворк не
поддерживаем и не тестируем).
3. Малый footprint и детерминированное использование памяти: без heap после
инициализации (все буферы — члены/статические), никаких исключений наружу
(внутри — `expected`/коды), логирование через callback.
4. Интеграция «как у climate-модулей ESPHome»: простой асинхронный API
(set/get свойства, колбэки обновлений, статус сессии).
5. Устойчивость: переживать re-key (модуль сам ротирует ключи каждые ≈44–60 с),
перезагрузку модуля, конфликт слотов (503), режим «KE без активации»;
жёсткий rate-control, чтобы не «завалить» модуль.
Не-цели (первая версия):
* Облако внутри C++-библиотеки. **lanip_key считается статичным, зашитым в
модуль** (5 лет эксплуатации без ротаций). Провижининг — отдельный Python
CLI (`fglair-discover`), см. §8. При несовпадении `key_id` библиотека
переходит в устойчивое состояние ошибки и ждёт смены конфига вручную.
* Узловые устройства (`node/*`), setup-режим (RSA `sec`), OTA.
* Hisense-свойства (`t_power` и пр.) — только FGLair-шаблоны A/B/F.
## 2. Архитектура
```
┌────────────────────────────────────────────────────────────┐
│ Приложение: HA-интеграция / ESPHome-компонент / CLI │
└───────────────▲────────────────────────────────────────────┘
│ include/fgl/*.h — публичный API
│ C++-классы + тонкий extern "C"-шейм (для cffi/HA)
┌───────────────┴────────────────────────────────────────────┐
│ core (портативный C++20, без исключений/RTTI/heap): │
│ session — машина состояний, re-key, keep-alive, слоты │
│ crypto — KDF, AES-256-CBC (цепочка!), HMAC-SHA256 │
│ envelope — pack/unpack {"enc","sign"}, seq_no, паддинг │
│ property — таблицы свойств шаблонов A/B/F, конверсии │
│ cmdq — очередь команд с coalescing + pacing │
│ json — минимальный streaming JSON reader/writer │
│ httpd — минимальный HTTP/1.1 server (роутинг по IP) │
│ httpc — клиент local_reg │
├────────────────────────────────────────────────────────────┤
│ platform layer (интерфейс fgl/platform.h, 2 реализации): │
│ posix : sockets, std::thread, timerfd, getrandom │
│ esp-idf: lwip sockets, esp_timer/FreeRTOS, esp_random │
├────────────────────────────────────────────────────────────┤
│ crypto backend: mbedtls (в ESP-IDF встроен; на Linux — │
│ системный или vendored) │
└────────────────────────────────────────────────────────────┘
```
Правила:
* Ядро не знает про ОС: сокеты/таймеры/логи/случайность — через тонкие
платформенные заголовки, реализуемые слоем ниже.
* Ядро однопоточное: один внутренний поток/задача владеет сессией и шифрами
(CBC-цепочка требует строгой сериализации). Вызовы API извне — через
потокобезопасный mailbox (lock-free SPSC или мьютекс). Все колбэки
исполняются из этого потока.
* C++20 разрешён и приветствуется (enum class, span, chrono, concepts),
но: без исключений, RTTI, виртуальных иерархий в горячем пути и heap после
`init()`. `std::function` в API не использовать (функция+user-data).
* Запрет на `printf`; логирование через injectable `fgl_log_fn`.
## 3. Публичный API (эскиз, `include/fgl/`)
```cpp
// fgl/types.h
enum class fgl_state { idle, registering, online, recovering, offline, key_error };
enum class fgl_prop { operation_mode, fan_speed, adjust_temperature,
display_temperature, af_vertical_direction, af_vertical_swing,
af_horizontal_direction, af_horizontal_swing, economy_mode,
powerful_mode, coil_dry_mode, min_heat, outdoor_low_noise,
indoor_fan_control, human_det_auto_save, wifi_led_enable,
op_status, error_code, device_capabilities, demand_control,
get_prop, device_name, building_name, /* ...по PROTOCOL.md §8.2 */ };
struct fgl_value { enum kind { boolean, integer, string } type;
union { bool b; int32_t i; }; const char* s; };
struct fgl_config {
const char* device_ip; // "192.0.2.3"
const char* dsn; // "AC000W00REDACTED"
const char* lanip_key; // base64-строка как есть
uint32_t lanip_key_id; // 62888
fgl_template template_; // A / B / F
uint16_t listen_port; // 0 => 10275
uint32_t keepalive_ms; // 0 => 15000 (рекомендация; APK: 10с)
uint8_t max_queue; // 0 => 16
};
struct fgl_callbacks {
void (*on_state)(void* user, fgl_state st, int err);
void (*on_property)(void* user, fgl_prop p, const fgl_value* v);
void (*on_log)(void* user, int level, const char* msg, size_t len);
void* user;
};
// fgl/session.h (C++-класс; в fgl/c_api.h — extern "C" шейм для cffi)
class FglSession {
public:
static FglSession* create(const fgl_config&, const fgl_callbacks&);
int start(); int stop(); // stop() шлёт delete_session, ждёт ≤2с
fgl_state state() const;
// Управление (ставится в очередь с coalescing):
int set_bool(fgl_prop, bool); int set_int(fgl_prop, int32_t);
int get_prop(fgl_prop); // запросить refresh
int batch_begin(); int batch_commit(); // атомарный пакет команд
bool cached(fgl_prop, fgl_value* out) const;
};
```
Таблица свойств — `const` массивы в rodata по шаблонам (имя, base_type,
read-only, диапазоны), см. PROTOCOL.md §8.
## 4. Поведенческие требования (обязательны к точной реализации)
Ссылки на PROTOCOL.md; всё, что ниже, согласовано с живыми тестами прибора.
### 4.1. Установка сессии
1. `start()`: HTTP-сервер слушает `listen_port` (по умолчанию 10275).
2. Отправить `POST /local_reg.json?dsn=<DSN>` с `notify=0` (§4.1). Ответы:
202 — ок; **503 — нет свободных слотов** (2 заняты, например телефоном и
другим сервером) → состояние `offline` с ошибкой `FGL_E_NO_SLOT`, повтор
с backoff 30–60 с; таймаут/отказ соединения → `offline`, backoff 1→60 с.
3. Дождаться `POST /local_lan/key_exchange.json` (обычно <1 с). Проверить
`ver==1, proto==1, sec пустой` (иначе 426/400), сверить `key_id`
(несовпадение → **412** + состояние `key_error` до смены конфига вручную;
облако НЕ дёргается — ключ статичен). Сгенерировать `random_2` (16 симв.
`[A-Za-z0-9]`), `time_2` (любое int64, напр. наносекунды аптайма),
вывести ключи (§3.2), ответить 200 `{"random_2":...,"time_2":...}`.
4. После ответа модуль в течение ~0.5 с делает «пустой» опрос commands.json —
это сигнал активации. **Если в течение 5 с опроса нет — сессия не
активировалась** (наблюдавшийся режим зависания модуля): закрыть серверную
сторону молча, уйти в `recovering` с паузой 30–60 с (НЕ долбить
повторными local_reg — ухудшает состояние модуля).
5. После активации: поставить пакет GET-команд нужных свойств (подписанное
подмножество таблицы, по умолчанию — все состояния + capabilities) и
отправить ОДИН `local_reg` с `notify=1`. Значения придут push'ами.
### 4.2. Keep-alive и re-key (ядро надёжности)
* Таймер `keepalive_ms` (по умолчанию **15000**). По истечении — `PUT local_reg`
с `notify = (очередь непуста)`.
* **Модуль сам ротирует ключи**: очередной `local_reg` при возрасте сессии
≥ ~44 с приходит вместе с новым key exchange. Обработать его как обычный
KE (перегенерация шифров, цепочки сбрасываются), НЕ пересоздавая сессию;
seq_no приложения продолжает глобальный счётчик. После re-key НЕ нужна
повторная начальная синхронизация (значения уже в кэше).
* Каждый обслуженный `GET /commands.json` перезапускает таймер keep-alive
(APK-поведение). Анти-спам: не более одного `local_reg` в ~1 с; notify=1
отправляется один раз на пакет команд.
* Модель времени: хранить `last_ke_time`; при `local_reg` предсказывать,
будет ли re-key (age ≥ 44 c) — для телеметрии/диагностики.
### 4.3. Очередь команд и pacing
* Ограничение очереди `max_queue` (16). Coalescing: новый write того же
свойства замещает предыдущий незабранный; GET-дубликаты отбрасываются.
* `commands.json`: отдать **одну** команду из головы; 206, если очередь
непуста, иначе 200. Шифрование строго последовательно (CBC-цепочка),
паддинг — Java-вариант (≥1 NUL).
* Записи не эхируются (проверено): после выдачи write обновить кэш
оптимистично; опционально (по конфигу) подтвердить GET-ом через 1–2 с.
### 4.4. Обработка сообщений модуля
* Datapoint push: расшифровать, проверить подпись; **seq_no не проверять**.
Парсить query `?cmd_id=N&status=200` для сопоставления с ожиданиями GET.
При ошибке расшифровки ответить **401** (APK-совместимость; модуль это
игнорирует, но таковы интерфейсные контракты) и пометить сессию
`recovering` — ждать ближайшего re-key по keep-alive (≤15 с).
* Повторный key exchange на живой сессии — штатное событие (§4.2), не ошибка.
### 4.5. Завершение
* `stop()`: поставить команду DELETE `local_reg.json`/`delete_session`,
дождаться выдачи (≤2 с), закрыть сервер. Освобождает слот немедленно
(проверено) — важно из-за лимита в 2 сессии.
### 4.6. HTTP-сервер
* Минимальный HTTP/1.1: GET/POST, `Content-Length`, keep-alive, query-параметры.
Один поток, последовательная обработка, RST-обрывы от модуля — норма.
* Роутинг на сессию по IP клиента (как `deviceWithLanIP` в APK). Мульти-сессии
(несколько устройств) — через `FglHub` (M6).
## 5. Криптография
* mbedtls: AES-256-CBC c сохранением IV-состояния между сообщениями
(mbedtls_aes_crypt_cbc обновляет iv-буфер на месте — использовать его же
как персистентное состояние), HMAC-SHA256 через `mbedtls_md`.
* KDF по §3.2 PROTOCOL. Тестовые векторы — эталон `tools/probe_reference.py`
(проверен на приборе) + генератор векторов на Python.
* `random_2`/`id` — из CSPRNG платформы. `time_2` — наносекунды аптайма.
## 6. Таблица свойств
`src/property.cpp` + `include/fgl/props.def`: на каждый шаблон — `constexpr`
массив `{enum, имя, base_type, RO, min/max}`; конверсии `adjust_temperature`
(×0.1 °C), `display_temperature` ((v−5000)/100), направления 0..num_dir−1;
битовые декодеры `op_status` / `device_capabilities` (§8.4–8.5).
## 7. Структура репозитория
```
fglair-core/
include/fgl/ # публичные заголовки (C++20 + c_api.h extern "C")
src/ # ядро (портативное)
platform/posix/ # sockets/std::thread/timerfd/getrandom
platform/esp-idf/ # lwip/esp_timer/esp_random (+ idf_component CMakeLists)
test/unit/ # KDF, envelope, json, property, cmdq (doctest/catch2)
test/integration/ # python mock-модуль (эталон probe_reference.py) + runner
tools/probe_reference.py# эталонный клиент, проверенный на приборе
tools/fglair-discover # CLI: облачный discovery -> печать/сохранение конфига
examples/cli/ # fglctl (linux): status/set/monitor
CMakeLists.txt # linux build + tests
README.md
```
Зависимости: mbedtls (IDF встроен; Linux — системный или FetchContent 3.x),
Python 3 (тесты/инструменты). Оценка ресурсов (ESP32, IDF): RAM < 20 КБ на
сессию, код ядра ~35–50 КБ + mbedtls.
## 8. Провижининг (вне библиотеки)
* `fglair-discover` (Python): вход e-mail/пароль/регион → облачные endpoints
(PROTOCOL.md §7) → печать `dsn, ip, oem_model, lanip_key, lanip_key_id` и
сохранение json-конфига (формат `config_kata.json`). Тот же код кладётся в
HA-интеграцию (config flow) и используется standalone для ESPHome-пользователей.
* Рантайм-обновления ключа НЕТ. Несовпадение `key_id` = `key_error`,
лечение — редактирование конфига вручную (для HA — repair-флоу с повторным
облаком; для ESPHome — копирование ключа из диагностики HA или повторный
запуск CLI).
## 9. Тестирование
1. **Unit**: KDF-векторы; envelope roundtrip (оба варианта паддинга); CBC-
цепочка (3 сообщения подряд); JSON writer/reader fuzz; coalescing; таблицы.
2. **Mock-модуль** (`test/integration/mock_ac.py`, развитие probe_reference.py):
сценарии: обычная сессия; re-key по возрасту 44 с (ускоренный таймер);
503-слоты; «KE без poll»; потеря сообщения → 401 → восстановление на
следующем keep-alive; delete_session.
3. **On-device** (чек-лист, фактически повторяет проведённые пробы):
старт/активация ≤5 с; GET всех свойств; запись + оптимистичное обновление;
24 ч uptime с keep-alive 15 с (лог: re-key каждые 45–60 с, 0 потерь);
параллельно телефон; перезапуск прибора питанием.
4. CI: gcc+clang -Wall -Werror, asan/ubsan (unit+mock), idf-сборка esp32.
## 10. Этапы (milestones)
| # | Содержимое | Критерии приёмки |
|---|------------|------------------|
| M0 | Каркас, платслой, логирование, CMake+IDF, CI | Собирается на linux и esp-idf; пустой HTTP-сервер отвечает 404 |
| M1 | Крипто: KDF + envelope + векторы | Векторы зелёные; совместимость с probe_reference.py |
| M2 | Сессия с mock-модулем: local_reg→KE→активация (poll)→GET→push; keep-alive | Mock-сценарий «обычная сессия»; на приборе: активация ≤5 с, свойства читаются |
| M3 | Очередь с coalescing, 206/200, batch, записи+оптимистичный кэш | На приборе: batch из 5 команд = 1 local_reg notify; значения применяются |
| M4 | re-key по возрасту, 401-обработка, 503/слоты, режим «KE без poll» (backoff), delete_session | Mock-сценарии + 2 ч на приборе без рассинхрона; stop() освобождает слот |
| M5 | Таблицы A/B/F + конверсии; fglctl; fglair-discover; probe_reference.py в tools/ | 24 ч на приборе: 0 рассинхронов, re-key каждые 45–60 с |
| M6 | (Опционально) mDNS-обнаружение (запрос на :10276), FglHub на N устройств | Устройство найдено без статического IP |
## 11. Риски и открытые вопросы
* Режим «KE без poll» (PROTOCOL.md §4.4 п.6) — причина не идентифицирована;
стратегия (пауза 30–60 с) подобрана эмпирически; заложить телеметрию для
уточнения.
* `display_temperature` → °C: формула (v−5000)/100 c округлением к 0.25;
расхождение с таблицей приложения ≤ 0.25 °C.
* Порог re-key ≈44 с измерен в границах 39–44 с — для надёжности опираться
не на точное значение, а на факт «KE может прийти с любым local_reg».
* В ESPHome/Arduino-сборках esp_http_server может быть занят портом 80 —
ядро использует собственный мини-httpd на lwip-сокетах, конфликтов нет.