Files
fgl-aircon/docs/PLAN_CORE_LIBRARY.md
Petr Polezhaev e74f3dc67a core(M2): машина состояний сессии Ayla LAN + mock-модуль + интеграционные сценарии
- 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.
2026-09-27 14:56:47 +03:00

23 KiB
Raw Blame History

План: базовая библиотека fgl-aircon (C++20, Linux + ESP-IDF) в монорепозитории

Целевая аудитория — агенты-реализаторы. Протокольные детали — в PROTOCOL.md (ссылки вида «§N»; факты помечены [ПРОВЕРЕНО НА ПРИБОРЕ]). Причины проектных решений — LEGACY_ANALYSIS.md.

0. Монорепозиторий

Все три компонента живут в одном репозитории:

/                             # CMakeLists.txt базовой библиотеки — в корне
  include/fgl-aircon/         # публичный API (уровень aircon)
  src/
    ayla/                     # реализация протокола + главный цикл сообщений
      platform/
        posix/                # сокеты/std::thread/timerfd/getrandom
        esp-idf/              # lwip-сокеты/esp_timer/esp_random
    aircon/                   # конверсии, шаблоны, реализация публичного API
  third_party/jsmn/           # вендоренный JSON-парсер (MIT)
  components/fglair/          # ESPHome external component (см. PLAN_ESPHOME)
  custom_components/fglair/   # HA custom component (см. PLAN_HOME_ASSISTANT)
  tests/
    ayla/                     # тесты протокола (KDF, envelope, http, сессия+mock)
    aircon/                   # тесты конверсий и шаблонов
    acceptance/               # скрипты приёмки ESPHome<->HA (см. §11)
  tools/                      # probe_reference.py, probe_mdns.py, fglair-discover
  docs/

Подключение к сборке:

  • ESP-IDF: корень репозитория регистрируется как компонент (idf_component.yml/CMakeLists.txt с idf_component_register); ESPHome- компонент (components/fglair) подключает его через EXTRA_COMPONENT_DIRS / relative path.
  • POSIX: cmake -S . -B build && cmake --build build — статическая библиотека fgl-aircon + цели тестов.

1. Слои библиотеки (логическое разделение, сборка — одна)

приложение (HA / ESPHome / fglctl)
   │  include/fgl-aircon/*.hpp      — публичный API
   ▼
src/aircon   — свойства и их семантика: таблицы шаблонов A/B/F,
               конверсии (шаблонные/линейные/функцией), кэш значений,
               адаптация к публичному API. Не знает про сеть.
   │  src/ayla/session.hpp (внутренний интерфейс)
   ▼
src/ayla     — протокол Ayla LAN: crypto/KDF, envelope, HTTP-сервер/клиент
               (mini-httpd/httpc), очередь команд с coalescing и pacing,
               главный цикл сообщений (один поток/задача), re-key, слоты,
               keep-alive. Не знает про свойства кондиционера.
   │
   ▼
src/ayla/platform/*  — сокеты, таймеры, CSPRNG, лог (posix | esp-idf)

Правила слоёв:

  • aircon → ayla → platform, зависимости только вниз.
  • ayla оперирует «непрозрачными» именами свойств (строки) и целыми — вся семантика (°C, режимы, флаги, битмаски) — в aircon.
  • Публичный API — только include/fgl-aircon/; внутренние заголовки лежат рядом с реализациями (src/ayla/*.hpp, src/aircon/*.hpp).

2. Цели и не-цели

Цели:

  1. Реализовать LAN-протокол FGLair/Ayla (сторона «приложения») по PROTOCOL.md, поведение — как у APK и подтверждено живыми тестами.
  2. Портативность: ESP-IDF (esp32/esp32s3/esp32c3, только IDF-фреймворк) и POSIX (Linux) из одной кодовой базы.
  3. Малый footprint: без heap после init(), без исключений/RTTI наружу, логирование через callback, статические буферы.
  4. Интеграция «как у climate-модулей ESPHome»: асинхронный API, колбэки.
  5. Устойчивость: re-key (модуль ротирует ключи каждые ≈44–60 с), перезагрузка модуля, слоты/503, режим «KE без активации»; жёсткий rate-control.

Не-цели (первая версия):

  • Облако в рантайме. lanip_key статичен (зашит в модуль, 5 лет без ротаций); провижининг — Python CLI tools/fglair-discover. Несовпадение key_id → устойчивое состояние ошибки до правки конфига вручную.
  • Узловые устройства, setup-режим (RSA), OTA.
  • Hisense-свойства (t_power и пр.) — только шаблоны A/B/F.

3. Публичный API (эскиз include/fgl-aircon/)

// types.hpp
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 §8.2 */ };
enum class fgl_template { A, B, F };
struct fgl_value { enum kind { boolean, integer, string } type;
                   bool b; int32_t i; const char* s; };

// --- конверсии: шаблон по умолчанию, коэффициенты или функция (см. §4) ---
enum class fgl_conv_kind { template_default, linear, custom_fn };
struct fgl_linear { int32_t num, den, offset; };     // disp = raw*num/den + offset
struct fgl_conversion {
  fgl_conv_kind kind = fgl_conv_kind::template_default;
  fgl_linear linear{};                               // при kind == linear
  int32_t (*fn)(int32_t raw, void* ctx) = nullptr;   // при kind == custom_fn
  void* ctx = nullptr;
};
struct fgl_prop_override {           // точечная настройка одного свойства
  fgl_prop prop;
  bool has_range = false; int32_t min = 0, max = 0;
  fgl_conversion to_display{};       // raw -> инженерные единицы
  fgl_conversion from_input{};       // ввод -> raw
};

struct fgl_config {
  const char* host;           // DNS-имя или IP ("ac.local" / "192.168.0.42")
  const char* dsn;            // "AC000W00XXXXXXX"
  const char* lanip_key;      // base64-строка как есть
  uint32_t    lanip_key_id;
  fgl_template template_;
  uint16_t    listen_port;    // 0 => 10275
  uint32_t    keepalive_ms;   // 0 => 15000
  uint8_t     max_queue;      // 0 => 16
  const fgl_prop_override* overrides = nullptr;   // nullptr-terminated
};
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;
};

// session.hpp (C++-классы; c_api.h — extern "C" шейм для cffi/HA)
class FglSession {
 public:
  static FglSession* create(const fgl_config&, const fgl_callbacks&);
  int start(); int stop();          // stop() шлёт delete_session (≤2с)
  fgl_state state() const;
  int set_bool(fgl_prop, bool); int set_int(fgl_prop, int32_t);
  int get_prop(fgl_prop);
  int batch_begin(); int batch_commit();
  bool cached(fgl_prop, fgl_value* out) const;
};

// templates.hpp — интроспекция шаблонов (для превью в HA, тестов, CLI):
//   список свойств шаблона, base_type, RO, диапазоны, примеры конверсии
const fgl_template_info* fgl_template_info_get(fgl_template);
int32_t fgl_convert_to_display(fgl_template, fgl_prop, int32_t raw,
                               const fgl_prop_override* ov);
int32_t fgl_convert_from_input(fgl_template, fgl_prop, int32_t disp,
                               const fgl_prop_override* ov);

4. Конверсии (требование: задаются руками при необходимости)

Каждое числовое свойство имеет конверсию по умолчанию из таблицы шаблона (adjust_temperature: ×0.1 °C; display_temperature: (v−5000)/100; направления: 0..N−1; перечисления — словари). Конфиг сессии может переопределить её для любого свойства одним из способов:

  1. Коэффициенты fgl_linear {num, den, offset} — для линейных величин (температуры, проценты). Без кода, доступно из HA UI и YAML.
  2. Функция-указатель int32_t (*)(int32_t raw, void* ctx) — произвольная логика; ctx для захваченных данных. ESPHome-лямбды компилируются в функции и передаются напрямую (пример — PLAN_ESPHOME §2); HA ограничен коэффициентами (и выбором шаблона).
  3. Диапазоны (min/max) переопределяются независимо от конверсии.

Конверсия применяется в src/aircon на границе API: наружу — «инженерные» единицы (0.1 °C уже умножено? — НЕТ: наружу отдаётся значение в единицах конверсии, см. ниже), в протокол — raw. Договорённость о единицах наружу фиксируется в README: наружу отдаётся результат to_display (для шаблона A температуры — °C×1 float-friendly int? — принимаем: наружу int32 в «инженерных» единицах, кратность задаёт конверсия; для HA cffi этого достаточно, ESPHome при желании делит сам через лямбду).

5. Поведенческие требования (обязательны к точной реализации)

5.1. Установка сессии

  1. start(): HTTP-сервер слушает listen_port (10275). Разрешение host: getaddrinfo (lwip DNS); для *.local — Ayla-mDNS запрос A-записи на 224.0.0.251:10276 (модуль НЕ отвечает на :5353 — проверено). Ретраи разрешения при недоступности.
  2. POST /local_reg.json?dsn=<DSN> с notify=0 (PROTOCOL §4.1). Ответы: 202 — ок; 503 — нет слотов (2 заняты) → offline/FGL_E_NO_SLOT, повтор 30–60 с; отказ соединения → offline, backoff 1→60 с.
  3. Дождаться key exchange (<1 с): ver/proto==1, sec=="" (иначе 426), сверка key_id (несовпадение → 412 + key_error до правки конфига). random_2 — 16 симв. [A-Za-z0-9], time_2 — наносекунды аптайма; вывести ключи (§3.2 PROTOCOL), ответить 200.
  4. Активация = «пустой» опрос commands.json в течение ~0.5 с. Нет опроса 5 с → сессия не активировалась (известный режим зависания модуля): тишина + recovering с паузой 30–60 с (НЕ долбить local_reg).
  5. После активации — пакет GET-команд свойств + ОДИН local_reg notify=1.

5.2. Keep-alive и re-key

  • Таймер keepalive_ms (default 15000); по истечении — PUT local_reg c notify=(очередь непуста). Каждый commands.json перезапускает таймер.
  • Re-key — событие по инициативе модуля (при зазоре local_reg ≥ ~44–50 с, [ПРОВЕРЕНО НА ПРИБОРЕ]; при штатном keep-alive НЕ происходит): обработать как обычный KE (перегенерация шифров/цепочек), сессию не пересоздавать, начальную синхронизацию не повторять; seq_no приложения продолжает глобальный счётчик.
  • Восстановление при ошибке расшифровки: тишина > порога (50 с по умолчанию) и возврат — модуль гарантированно ре-кает [ПРОВЕРЕНО НА ПРИБОРЕ].
  • Анти-спам: ≤1 local_reg/с; notify=1 — один на пакет команд.

5.3. Очередь команд

  • Лимит max_queue (16); coalescing (write замещает write, GET-дубликаты отбрасываются). commands.json: одна команда из головы; 206/200; шифрование строго последовательно; паддинг Java-вариант (≥1 NUL).
  • Записи не эхируются (проверено): оптимистичное обновление кэша; опционально GET-подтверждение через 1–2 с.

5.4. Входящие сообщения

  • Datapoint push: расшифровка, подпись; seq_no не проверяется. Query ?cmd_id=N&status=200 — сопоставление GET-ожиданий. Ошибка расшифровки → 401 (APK-совместимость) + recovering (самолечение — ближайший keep-alive/re-key, ≤15 с).
  • Ответы на GET также доставляются через datapoint push (в ayla они прозрачны наверх как обновления свойства).

5.5. Завершение

  • stop(): DELETE local_reg.json/delete_session, выдача ≤2 с, закрыть сервер. Слот освобождается немедленно (проверено).

6. Криптография

mbedtls (в ESP-IDF встроен; POSIX — системный или FetchContent): AES-256-CBC с персистентным IV-состоянием между сообщениями (mbedtls_aes_crypt_cbc обновляет iv-буфер на месте — использовать его как состояние цепочки), HMAC-SHA256 через mbedtls_md. KDF — PROTOCOL §3.2; тестовые векторы — эталон tools/probe_reference.py (проверен на приборе).

7. HTTP и JSON: оценка готовых библиотек (решение)

Требования: no-heap после init, один код для ESP-IDF и POSIX, точный контроль поведения (keep-alive/RST-quirks модуля измерены на приборе), малый footprint.

JSON (парсер):

Кандидат Оценка
ESP-IDF cJSON/esp_json DOM + malloc на узлы → конфликтует с no-heap; IDF-only
ArduinoJson (есть в ESPHome) v7 лишился zero-alloc-режима (StaticJsonDocument удалён); тянет зависимость ESPHome
RapidJSON (SAX + custom allocator) Подходит технически, но тяжеловат (~10k строк) для наших 5 форматов сообщений
jsmn (MIT, 2 файла, ~300 строк) Принято: токеновый парсер без аллокаций, стандарт де-факто embedded, раньше входил в ESP-IDF. Вендорим в third_party/jsmn/ — единый код для обеих платформ

Writer — собственный, с фиксированным буфером (~100 строк; наши данные не требуют сложного экранирования).

HTTP-сервер (входящие от модуля) и HTTP-клиент (local_reg):

Кандидат Оценка
ESP-IDF esp_http_server Есть, но: IDF-only (нужна вторая реализация для POSIX); роутинг по IP клиента — хак (httpd_req_to_sockfd + getpeername); меньше контроля над keep-alive/RK-quirks
cpp-httplib (POSIX) Удобен, но heap + вторая ветка кода; LGPL/MIT ок
mongoose / civetweb / libmicrohttpd Лицензии/вес избыточны

Решение: собственный мини-httpd + мини-httpc в src/ayla (~300+100 строк) поверх BSD-сокетов — lwip на ESP32 и glibc дают идентичный API, одна реализация, полный контроль. esp_http_server/третьилицевые httpd НЕ используем. HTTP-клиент (один POST/PUT с Content-Length) — тривиален.

Conan: не нужен — единственная внешняя зависимость (jsmn) вендорится. Если позже появятся POSIX-only зависимости (например, TLS для облачного инструмента) — подключить conan только для POSIX-ветки.

8. Таблицы свойств и конверсии (src/aircon)

constexpr-массивы на шаблон: {enum, имя, base_type, RO, диапазон raw, конверсия по умолчанию, словари перечислений}. Битовые декодеры op_status/device_capabilities (PROTOCOL §8.4–8.5). Точка расширения — fgl_prop_override (§4). Интроспекция fgl_template_info_get используется HA-превью (PLAN_HOME_ASSISTANT §4) и тестами.

9. Тесты (разделение как у src)

  • tests/ayla/ — протокол: KDF-векторы; envelope roundtrip (оба паддинга); CBC-цепочка (3 сообщения); мини-httpd/httpc (запросы модуля, keep-alive, RST); очередь/coalescing/pacing; машина состояний с mock-модулем (tests/ayla/mock_ac.py — развитие probe_reference.py): обычная сессия, re-key по возрасту, 503-слоты, «KE без poll», потеря сообщения → 401 → восстановление, delete_session.
  • tests/aircon/ — конверсии и шаблоны: все свойства всех шаблонов; линейные/функциональные override; диапазоны; битмаски; согласованность таблиц с PROTOCOL §8 (значения из APK).
  • CI: gcc+clang -Wall -Werror, asan/ubsan, ctest; idf-сборка esp32.

10. Этапы

# Содержимое Критерии приёмки
M0 ✅ Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI Собирается linux+esp-idf; пустой httpd отвечает 404
M1 ✅ src/ayla: crypto+envelope, мини-httpd/httpc, jsmn-вендор Векторы зелёные; httpd-тесты; совместимость с probe_reference.py
M2 ✅ src/ayla: сессия (установка/активация/keep-alive/re-key/слоты/503/delete) с mock-модулем Все сценарии mock; на приборе: активация ≤5 с; семантика re-key: при зазоре local_reg ≥ ~44–50 с (при честном keep-alive 15 с — 0 re-key за 100 с; при 50 с — 3 re-key)
M3 src/aircon: шаблоны, конверсии+override, публичный API, batch tests/aircon зелёные; на приборе: чтение всех свойств, batch=1 notify
M4 fglctl-пример, tools/fglair-discover (в т.ч. --format esphome-secrets), README библиотеки (сборка IDF/POSIX, тесты) 24 ч на приборе: 0 рассинхронов; README готов
M5 (Опция) FglHub N устройств; mDNS-резолвер как опция host-разрешения Два устройства одновременно

README.md библиотеки (после M4, для людей): сборка в ESP-IDF (как компонент), сборка POSIX (cmake), запуск тестов (ctest + mock), краткий пример API. Максимально коротко.

11. Приёмка ESPHome↔HA (скрипт в tests/acceptance/)

Полуавтоматизированный тест сквозной согласованности двух интеграций, работающих с одним кондиционером (занимают оба слота модуля — скрипт сам третью сессию НЕ открывает). Детали и авторизация — PLAN_ESPHOME §8 / PLAN_HOME_ASSISTANT §7 (HA long-lived access token + REST API — проверено, стандартный механизм; ESPHome — официальный aioesphomeapi). Режимы: quick (матрица изменений burst/не-burst, обе стороны, с возвратом) и --long (24 ч, раз в час одно изменение с проверкой и возвратом; CSV-отчёт). Запускается вручную; входит в чек-лист релиза.

12. Риски

  • Режим «KE без poll» (PROTOCOL §4.4 п.6) — причина не идентифицирована; стратегия (пауза 30–60 с) эмпирическая; заложить телеметрию.
  • Порог re-key ≈44 с (границы 39–44 с) — не опираться на точное значение.
  • Конверсии через int32_t num/den — следить за переполнением (int64 промежуточно).