- 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.
325 lines
23 KiB
Markdown
325 lines
23 KiB
Markdown
# План: базовая библиотека `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
|
||
промежуточно).
|