Files
fgl-aircon/docs/PLAN_CORE_LIBRARY.md
Petr Polezhaev 157df4e795 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

21 KiB
Raw Blame History

План: кросс-платформенная библиотека 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/)

// 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.168.88.3"
  const char* dsn;            // "AC000W002879281"
  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-сокетах, конфликтов нет.