# План: кросс-платформенная библиотека `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=` с `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-сокетах, конфликтов нет.