Files
fgl-aircon/docs/PLAN_CORE_LIBRARY.md
Petr Polezhaev b44004627b core(M0): монорепо-каркас — CMake (posix+esp-idf), платслой, лог, мини-httpd
- CMakeLists в корне: ветвление ESP_PLATFORM (idf_component_register,
  lwip/esp_timer/esp_hw_support/pthread) / POSIX (статическая библиотека
  fgl-aircon, C++20, -fno-exceptions -fno-rtti, -Werror).
- src/ayla/platform: сокеты/потоки/CSPRNG/время; posix (getrandom, poll,
  pthread_join) и esp-idf (lwip_select, esp_fill_random, pthread-слой IDF);
  tcp_shutdown/tcp_local_port/thread_join для управляемой остановки.
- src/ayla: log (sink, без printf); мини-httpd/1.1 (keep-alive, Content-Length,
  лимиты заголовков/тела, ephemeral-порт, жизненный цикл с гарантией
  завершения потока: shutdown(active)→join→close).
- tests/ayla: platform (join, loopback+shutdown) и httpd (404, keep-alive,
  обработчик/парсинг, oversize-400, stop при живом соединении, стрим
  заголовков). doctest через FetchContent.
- scripts/ci.sh: сборка+ctest. ESP-IDF v5.5.5 esp32: смоук-сборка с ядром
  как компонентом — Project build complete.
Ревью под-агентом: 3 круга, все блокеры (жизненный цикл httpd) закрыты, APPROVED.
2026-09-22 00:27:33 +03:00

321 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План: базовая библиотека `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=<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 с) — штатное
событие: перегенерация шифров/цепочек, сессия не пересоздаётся, начальная
синхронизация не повторяется; 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()`: 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 каждые 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
промежуточно).