Files
fgl-aircon/docs/PLAN_CORE_LIBRARY.md
Petr Polezhaev bfdf22ba64 core(M2): машина состояний сессии Ayla LAN + mock-модуль + интеграционные сценарии
- session.{hpp,cpp}: state machine (idle/registering/online/recovering/
  offline/key_error); httpd-обработчики key_exchange (200/426/412, re-key
  прозрачно), commands (одна команда, 206/200, envelope, глобальный seq_no),
  datapoint (unpack -> PropertyEvent / 401+тишина 50с для re-key-восстановления);
  сессионный поток: local_reg POST?dsn/PUT (local_ip_for), keep-alive, backoff
  x1.6->60с, 503->offline/NoSlot, activation-timeout->recovering, delete_session
  с ожиданием выдачи; очередь с coalescing + batch; телеметрия; колбэки из
  двух потоков с задокументированным контрактом; буферы datapoint-пути в Impl.
- platform: local_ip_for (UDP-connect) posix+esp-idf; стек httpd 24576
  (переполнение 16КБ поймано gdb на Release).
- mock_ac.py: мок-модуль, stdlib-only чистый python AES-256 (свёрстан с
  pycryptodome); сценарии: 503, no-poll, rekey-every, stale-gap (эмуляция
  'вернувшегося' приложения), fail-pushes (битая подпись), garbage-pushes
  (обрыв блока), break-outbound (исходящий десинк -> модуль ре-кает на
  local_reg, как probe1-3), push-every, fail-first-ke.
- session_runner + test_session_mock.py: 9 сценариев через ctest, включая
  самосинхронизацию CBC и восстановление после исходящего десинка.
- Прибор AP-WC1E: активация <=1с; re-key семантика ИСПРАВЛЕНА по живым
  тестам: re-key при зазоре local_reg >= ~44-50с (не по возрасту сессии!);
  при честном keep-alive 15с сессия стабильна без re-key; PROTOCOL/LEGACY/
  PLAN обновлены; восстановление = тишина >порога + возврат.
- CI: 7/7 x3 (gcc-Rel, gcc-ASan/UBSan, clang); ESP-IDF esp32 build complete.
Ревью под-агентом: 2 круга (стек httpd, залипание состояний, dangling cfg,
физика десинка) — APPROVED.
2026-09-27 10:53:28 +03:00

325 lines
23 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–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
промежуточно).