- 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 исключены из версионирования.
21 KiB
План: кросс-платформенная библиотека fglair-core (C++20, Linux + ESP-IDF)
Целевая аудитория документа — агенты-реализаторы. Протокольные детали — в
PROTOCOL.md (ссылки вида «§N», факты помечены [ПРОВЕРЕНО НА ПРИБОРЕ]).
Причины проектных решений — LEGACY_ANALYSIS.md.
1. Цели и не-цели
Цели:
- Реализовать LAN-протокол FGLair/Ayla (сторона «приложения») по спецификации
PROTOCOL.mdс поведением, максимально близким к официальному APK и подтверждённому живыми тестами прибора. - Портативность: сборка как C++20-библиотека (Linux, CMake) и как компонент ESP-IDF (esp32/esp32s3/esp32c3 — везде ESP-IDF, Arduino-фреймворк не поддерживаем и не тестируем).
- Малый footprint и детерминированное использование памяти: без heap после
инициализации (все буферы — члены/статические), никаких исключений наружу
(внутри —
expected/коды), логирование через callback. - Интеграция «как у climate-модулей ESPHome»: простой асинхронный API (set/get свойства, колбэки обновлений, статус сессии).
- Устойчивость: переживать re-key (модуль сам ротирует ключи каждые ≈44–60 с), перезагрузку модуля, конфликт слотов (503), режим «KE без активации»; жёсткий rate-control, чтобы не «завалить» модуль.
Не-цели (первая версия):
- Облако внутри C++-библиотеки. lanip_key считается статичным, зашитым в
модуль (5 лет эксплуатации без ротаций). Провижининг — отдельный Python
CLI (
fglair-discover), см. §8. При несовпаденииkey_idбиблиотека переходит в устойчивое состояние ошибки и ждёт смены конфига вручную. - Узловые устройства (
node/*), setup-режим (RSAsec), 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; логирование через injectablefgl_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. Установка сессии
start(): HTTP-сервер слушаетlisten_port(по умолчанию 10275).- Отправить
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 с. - Дождаться
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":...}. - После ответа модуль в течение ~0.5 с делает «пустой» опрос commands.json —
это сигнал активации. Если в течение 5 с опроса нет — сессия не
активировалась (наблюдавшийся режим зависания модуля): закрыть серверную
сторону молча, уйти в
recoveringс паузой 30–60 с (НЕ долбить повторными local_reg — ухудшает состояние модуля). - После активации: поставить пакет 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(): поставить команду DELETElocal_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. Тестирование
- Unit: KDF-векторы; envelope roundtrip (оба варианта паддинга); CBC- цепочка (3 сообщения подряд); JSON writer/reader fuzz; coalescing; таблицы.
- Mock-модуль (
test/integration/mock_ac.py, развитие probe_reference.py): сценарии: обычная сессия; re-key по возрасту 44 с (ускоренный таймер); 503-слоты; «KE без poll»; потеря сообщения → 401 → восстановление на следующем keep-alive; delete_session. - On-device (чек-лист, фактически повторяет проведённые пробы): старт/активация ≤5 с; GET всех свойств; запись + оптимистичное обновление; 24 ч uptime с keep-alive 15 с (лог: re-key каждые 45–60 с, 0 потерь); параллельно телефон; перезапуск прибора питанием.
- 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-сокетах, конфликтов нет.