- PLAN_CORE: монорепозиторий (CMakeLists в корне; components/fglair,
custom_components/fglair, include/fgl-aircon, src/{ayla,aircon},
src/ayla/platform); логическое разделение ayla (протокол+цикл) /
aircon (конверсии+шаблоны+API); тесты зеркалят слои (tests/{ayla,aircon});
конверсии: шаблон / линейные коэффициенты / функция-указатель
(лямбды ESPHome); оценка httpd/json (jsmn вендор, свой мини-httpd на
BSD-сокетах, conan не нужен); README библиотеки в M4.
- PLAN_ESPHOME: host вместо ip_address (DNS + Ayla-mDNS :10276),
секреты в примерах, кастомные конверсии через !lambda, advanced-пример
(триггеры режимов + LVGL с пропусками), README-план, скрипт приёмки
(aioesphomeapi + HA REST).
- PLAN_HOME_ASSISTANT: шаг config flow с превью рассчитанных значений
шаблона (через cffi в C-ядро, без дублей), README с HACS-инструкцией
и заглушками под скриншоты с описаниями, приёмка (long-lived token).
- Убраны реальные dsn/ip/lanip_key/key_id из примеров; probe_mdns.py
принимает DSN аргументом.
22 KiB
План: базовая библиотека 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. Цели и не-цели
Цели:
- Реализовать LAN-протокол FGLair/Ayla (сторона «приложения») по
PROTOCOL.md, поведение — как у APK и подтверждено живыми тестами. - Портативность: ESP-IDF (esp32/esp32s3/esp32c3, только IDF-фреймворк) и POSIX (Linux) из одной кодовой базы.
- Малый footprint: без heap после
init(), без исключений/RTTI наружу, логирование через callback, статические буферы. - Интеграция «как у climate-модулей ESPHome»: асинхронный API, колбэки.
- Устойчивость: re-key (модуль ротирует ключи каждые ≈44–60 с), перезагрузка модуля, слоты/503, режим «KE без активации»; жёсткий rate-control.
Не-цели (первая версия):
- Облако в рантайме.
lanip_keyстатичен (зашит в модуль, 5 лет без ротаций); провижининг — Python CLItools/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; перечисления — словари). Конфиг сессии может переопределить
её для любого свойства одним из способов:
- Коэффициенты
fgl_linear {num, den, offset}— для линейных величин (температуры, проценты). Без кода, доступно из HA UI и YAML. - Функция-указатель
int32_t (*)(int32_t raw, void* ctx)— произвольная логика;ctxдля захваченных данных. ESPHome-лямбды компилируются в функции и передаются напрямую (пример — PLAN_ESPHOME §2); HA ограничен коэффициентами (и выбором шаблона). - Диапазоны (
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. Установка сессии
start(): HTTP-сервер слушаетlisten_port(10275). Разрешениеhost:getaddrinfo(lwip DNS); для*.local— Ayla-mDNS запрос A-записи на224.0.0.251:10276(модуль НЕ отвечает на :5353 — проверено). Ретраи разрешения при недоступности.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 с.- Дождаться 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. - Активация = «пустой» опрос commands.json в течение ~0.5 с. Нет опроса
5 с → сессия не активировалась (известный режим зависания модуля):
тишина +
recoveringс паузой 30–60 с (НЕ долбить local_reg). - После активации — пакет GET-команд свойств + ОДИН
local_reg notify=1.
5.2. Keep-alive и re-key
- Таймер
keepalive_ms(default 15000); по истечении —PUT local_regcnotify=(очередь непуста). Каждыйcommands.jsonперезапускает таймер. - Re-key (очередной local_reg при возрасте сессии ≥ ~44 с) — штатное событие: перегенерация шифров/цепочек, сессия не пересоздаётся, начальная синхронизация не повторяется; seq_no приложения продолжает глобальный счётчик.
- Анти-спам: ≤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(): DELETElocal_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 каждые 45–60 с |
| 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 промежуточно).