# План: базовая библиотека `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/`) ```cpp // 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=` с `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 промежуточно).