docs: корректировки планов — монорепо, слои ayla/aircon, конверсии, приёмка
- 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 аргументом.
This commit is contained in:
@@ -1,79 +1,92 @@
|
||||
# План: кросс-платформенная библиотека `fglair-core` (C++20, Linux + ESP-IDF)
|
||||
# План: базовая библиотека `fgl-aircon` (C++20, Linux + ESP-IDF) в монорепозитории
|
||||
|
||||
Целевая аудитория документа — агенты-реализаторы. Протокольные детали — в
|
||||
`PROTOCOL.md` (ссылки вида «§N», факты помечены [ПРОВЕРЕНО НА ПРИБОРЕ]).
|
||||
Целевая аудитория — агенты-реализаторы. Протокольные детали — в
|
||||
`PROTOCOL.md` (ссылки вида «§N»; факты помечены [ПРОВЕРЕНО НА ПРИБОРЕ]).
|
||||
Причины проектных решений — `LEGACY_ANALYSIS.md`.
|
||||
|
||||
## 1. Цели и не-цели
|
||||
## 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. Портативность: сборка как C++20-библиотека (Linux, CMake) и как компонент
|
||||
ESP-IDF (esp32/esp32s3/esp32c3 — везде ESP-IDF, Arduino-фреймворк не
|
||||
поддерживаем и не тестируем).
|
||||
3. Малый footprint и детерминированное использование памяти: без heap после
|
||||
инициализации (все буферы — члены/статические), никаких исключений наружу
|
||||
(внутри — `expected`/коды), логирование через callback.
|
||||
4. Интеграция «как у climate-модулей ESPHome»: простой асинхронный API
|
||||
(set/get свойства, колбэки обновлений, статус сессии).
|
||||
5. Устойчивость: переживать re-key (модуль сам ротирует ключи каждые ≈44–60 с),
|
||||
перезагрузку модуля, конфликт слотов (503), режим «KE без активации»;
|
||||
жёсткий rate-control, чтобы не «завалить» модуль.
|
||||
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.
|
||||
|
||||
Не-цели (первая версия):
|
||||
* Облако внутри C++-библиотеки. **lanip_key считается статичным, зашитым в
|
||||
модуль** (5 лет эксплуатации без ротаций). Провижининг — отдельный Python
|
||||
CLI (`fglair-discover`), см. §8. При несовпадении `key_id` библиотека
|
||||
переходит в устойчивое состояние ошибки и ждёт смены конфига вручную.
|
||||
* Узловые устройства (`node/*`), setup-режим (RSA `sec`), OTA.
|
||||
* Hisense-свойства (`t_power` и пр.) — только FGLair-шаблоны A/B/F.
|
||||
* Облако в рантайме. `lanip_key` статичен (зашит в модуль, 5 лет без ротаций);
|
||||
провижининг — Python CLI `tools/fglair-discover`. Несовпадение `key_id` →
|
||||
устойчивое состояние ошибки до правки конфига вручную.
|
||||
* Узловые устройства, setup-режим (RSA), OTA.
|
||||
* Hisense-свойства (`t_power` и пр.) — только шаблоны 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`; логирование через injectable `fgl_log_fn`.
|
||||
|
||||
## 3. Публичный API (эскиз, `include/fgl/`)
|
||||
## 3. Публичный API (эскиз `include/fgl-aircon/`)
|
||||
|
||||
```cpp
|
||||
// fgl/types.h
|
||||
// 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,
|
||||
@@ -81,19 +94,37 @@ enum class fgl_prop { operation_mode, fan_speed, adjust_temperature,
|
||||
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 */ };
|
||||
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;
|
||||
union { bool b; int32_t i; }; const char* s; };
|
||||
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* device_ip; // "192.0.2.3"
|
||||
const char* dsn; // "AC000W00REDACTED"
|
||||
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; // 62888
|
||||
fgl_template template_; // A / B / F
|
||||
uint32_t lanip_key_id;
|
||||
fgl_template template_;
|
||||
uint16_t listen_port; // 0 => 10275
|
||||
uint32_t keepalive_ms; // 0 => 15000 (рекомендация; APK: 10с)
|
||||
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);
|
||||
@@ -102,172 +133,188 @@ struct fgl_callbacks {
|
||||
void* user;
|
||||
};
|
||||
|
||||
// fgl/session.h (C++-класс; в fgl/c_api.h — extern "C" шейм для cffi)
|
||||
// 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с
|
||||
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(); // атомарный пакет команд
|
||||
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);
|
||||
```
|
||||
|
||||
Таблица свойств — `const` массивы в rodata по шаблонам (имя, base_type,
|
||||
read-only, диапазоны), см. PROTOCOL.md §8.
|
||||
## 4. Конверсии (требование: задаются руками при необходимости)
|
||||
|
||||
## 4. Поведенческие требования (обязательны к точной реализации)
|
||||
Каждое числовое свойство имеет конверсию по умолчанию из таблицы шаблона
|
||||
(`adjust_temperature`: ×0.1 °C; `display_temperature`: (v−5000)/100;
|
||||
направления: 0..N−1; перечисления — словари). Конфиг сессии может переопределить
|
||||
её для любого свойства одним из способов:
|
||||
|
||||
Ссылки на PROTOCOL.md; всё, что ниже, согласовано с живыми тестами прибора.
|
||||
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`) переопределяются независимо от конверсии.
|
||||
|
||||
### 4.1. Установка сессии
|
||||
1. `start()`: HTTP-сервер слушает `listen_port` (по умолчанию 10275).
|
||||
2. Отправить `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 с.
|
||||
3. Дождаться `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":...}`.
|
||||
4. После ответа модуль в течение ~0.5 с делает «пустой» опрос commands.json —
|
||||
это сигнал активации. **Если в течение 5 с опроса нет — сессия не
|
||||
активировалась** (наблюдавшийся режим зависания модуля): закрыть серверную
|
||||
сторону молча, уйти в `recovering` с паузой 30–60 с (НЕ долбить
|
||||
повторными local_reg — ухудшает состояние модуля).
|
||||
5. После активации: поставить пакет GET-команд нужных свойств (подписанное
|
||||
подмножество таблицы, по умолчанию — все состояния + capabilities) и
|
||||
отправить ОДИН `local_reg` с `notify=1`. Значения придут push'ами.
|
||||
Конверсия применяется в `src/aircon` на границе API: наружу — «инженерные»
|
||||
единицы (0.1 °C уже умножено? — НЕТ: наружу отдаётся значение в единицах
|
||||
конверсии, см. ниже), в протокол — raw. Договорённость о единицах наружу
|
||||
фиксируется в README: наружу отдаётся результат `to_display` (для шаблона A
|
||||
температуры — °C×1 float-friendly int? — принимаем: наружу int32 в
|
||||
«инженерных» единицах, кратность задаёт конверсия; для HA cffi этого
|
||||
достаточно, ESPHome при желании делит сам через лямбду).
|
||||
|
||||
### 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) — для телеметрии/диагностики.
|
||||
## 5. Поведенческие требования (обязательны к точной реализации)
|
||||
|
||||
### 4.3. Очередь команд и pacing
|
||||
* Ограничение очереди `max_queue` (16). Coalescing: новый write того же
|
||||
свойства замещает предыдущий незабранный; GET-дубликаты отбрасываются.
|
||||
* `commands.json`: отдать **одну** команду из головы; 206, если очередь
|
||||
непуста, иначе 200. Шифрование строго последовательно (CBC-цепочка),
|
||||
паддинг — Java-вариант (≥1 NUL).
|
||||
* Записи не эхируются (проверено): после выдачи write обновить кэш
|
||||
оптимистично; опционально (по конфигу) подтвердить GET-ом через 1–2 с.
|
||||
### 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`.
|
||||
|
||||
### 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), не ошибка.
|
||||
### 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 — один на пакет команд.
|
||||
|
||||
### 4.5. Завершение
|
||||
* `stop()`: поставить команду DELETE `local_reg.json`/`delete_session`,
|
||||
дождаться выдачи (≤2 с), закрыть сервер. Освобождает слот немедленно
|
||||
(проверено) — важно из-за лимита в 2 сессии.
|
||||
### 5.3. Очередь команд
|
||||
* Лимит `max_queue` (16); coalescing (write замещает write, GET-дубликаты
|
||||
отбрасываются). `commands.json`: одна команда из головы; 206/200;
|
||||
шифрование строго последовательно; паддинг Java-вариант (≥1 NUL).
|
||||
* Записи не эхируются (проверено): оптимистичное обновление кэша;
|
||||
опционально GET-подтверждение через 1–2 с.
|
||||
|
||||
### 4.6. HTTP-сервер
|
||||
* Минимальный HTTP/1.1: GET/POST, `Content-Length`, keep-alive, query-параметры.
|
||||
Один поток, последовательная обработка, RST-обрывы от модуля — норма.
|
||||
* Роутинг на сессию по IP клиента (как `deviceWithLanIP` в APK). Мульти-сессии
|
||||
(несколько устройств) — через `FglHub` (M6).
|
||||
### 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.5. Завершение
|
||||
* `stop()`: DELETE `local_reg.json`/`delete_session`, выдача ≤2 с, закрыть
|
||||
сервер. Слот освобождается немедленно (проверено).
|
||||
|
||||
* 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. Криптография
|
||||
|
||||
## 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` (проверен на приборе).
|
||||
|
||||
`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. HTTP и JSON: оценка готовых библиотек (решение)
|
||||
|
||||
## 7. Структура репозитория
|
||||
Требования: no-heap после init, один код для ESP-IDF и POSIX, точный
|
||||
контроль поведения (keep-alive/RST-quirks модуля измерены на приборе),
|
||||
малый footprint.
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
**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/` — единый код для обеих платформ |
|
||||
|
||||
Зависимости: mbedtls (IDF встроен; Linux — системный или FetchContent 3.x),
|
||||
Python 3 (тесты/инструменты). Оценка ресурсов (ESP32, IDF): RAM < 20 КБ на
|
||||
сессию, код ядра ~35–50 КБ + mbedtls.
|
||||
Writer — собственный, с фиксированным буфером (~100 строк; наши данные не
|
||||
требуют сложного экранирования).
|
||||
|
||||
## 8. Провижининг (вне библиотеки)
|
||||
**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 | Лицензии/вес избыточны |
|
||||
|
||||
* `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).
|
||||
**Решение:** собственный мини-httpd + мини-httpc в `src/ayla` (~300+100
|
||||
строк) поверх BSD-сокетов — lwip на ESP32 и glibc дают идентичный API, одна
|
||||
реализация, полный контроль. `esp_http_server`/третьилицевые httpd НЕ
|
||||
используем. HTTP-клиент (один POST/PUT с Content-Length) — тривиален.
|
||||
|
||||
## 9. Тестирование
|
||||
**Conan:** не нужен — единственная внешняя зависимость (jsmn) вендорится.
|
||||
Если позже появятся POSIX-only зависимости (например, TLS для облачного
|
||||
инструмента) — подключить conan только для POSIX-ветки.
|
||||
|
||||
1. **Unit**: KDF-векторы; envelope roundtrip (оба варианта паддинга); CBC-
|
||||
цепочка (3 сообщения подряд); JSON writer/reader fuzz; coalescing; таблицы.
|
||||
2. **Mock-модуль** (`test/integration/mock_ac.py`, развитие probe_reference.py):
|
||||
сценарии: обычная сессия; re-key по возрасту 44 с (ускоренный таймер);
|
||||
503-слоты; «KE без poll»; потеря сообщения → 401 → восстановление на
|
||||
следующем keep-alive; delete_session.
|
||||
3. **On-device** (чек-лист, фактически повторяет проведённые пробы):
|
||||
старт/активация ≤5 с; GET всех свойств; запись + оптимистичное обновление;
|
||||
24 ч uptime с keep-alive 15 с (лог: re-key каждые 45–60 с, 0 потерь);
|
||||
параллельно телефон; перезапуск прибора питанием.
|
||||
4. CI: gcc+clang -Wall -Werror, asan/ubsan (unit+mock), idf-сборка esp32.
|
||||
## 8. Таблицы свойств и конверсии (src/aircon)
|
||||
|
||||
## 10. Этапы (milestones)
|
||||
`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; пустой 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 |
|
||||
| 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-разрешения | Два устройства одновременно |
|
||||
|
||||
## 11. Риски и открытые вопросы
|
||||
README.md библиотеки (после M4, для людей): сборка в ESP-IDF (как
|
||||
компонент), сборка POSIX (cmake), запуск тестов (ctest + mock), краткий
|
||||
пример API. Максимально коротко.
|
||||
|
||||
* Режим «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-сокетах, конфликтов нет.
|
||||
## 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
|
||||
промежуточно).
|
||||
|
||||
@@ -1,153 +1,235 @@
|
||||
# План: внешний компонент ESPHome (`fglair`)
|
||||
# План: компонент ESPHome `fglair` (components/fglair)
|
||||
|
||||
Аудитория — агенты-реализаторы. Протокол — `docs/PROTOCOL.md`; ядро —
|
||||
`docs/PLAN_CORE_LIBRARY.md` (`fglair-core`, C++20). Компонент строится по
|
||||
образцу штатных climate-модулей ESPHome (midea, hisense-ac, tuya), но протокол
|
||||
вынесен в переиспользуемое C++-ядро.
|
||||
Аудитория — агенты-реализатели. Протокол — `docs/PROTOCOL.md`; ядро —
|
||||
`docs/PLAN_CORE_LIBRARY.md` (`fgl-aircon`, C++20, монорепо: библиотека в
|
||||
корне, компонент здесь). Требования к среде: **только ESP-IDF framework**
|
||||
(Arduino-фреймворк ESPHome не поддерживаем).
|
||||
|
||||
Требования к среде: **только ESP-IDF framework** (Arduino-фреймворк ESPHome
|
||||
считается устаревшим и не поддерживается). Ядро — C++20 без исключений/RTTI,
|
||||
что совместимо с дефолтными флагами сборки ESPHome для IDF.
|
||||
|
||||
## 1. Распределение кода
|
||||
## 1. Структура
|
||||
|
||||
```
|
||||
esphome-fglair/ (external component, установка через
|
||||
components/fglair/ external_components: - source: github://...)
|
||||
__init__.py # FglairHub: Component; владеет FglSession ядра
|
||||
climate.py # FglairClimate : climate::Climate
|
||||
sensor.py # комнатная температура, error_code, op_status-флаги
|
||||
switch.py # economy/powerful/coil_dry/min_heat/...
|
||||
select.py # положения заслонок (v2)
|
||||
binary_sensor.py # connectivity
|
||||
config_validation.py
|
||||
const.py
|
||||
core/ # git-subtree/symlink fglair-core (src+include+platform/esp-idf)
|
||||
CMakeLists.txt # STATIC LIBRARY; REQUIRES lwip esp_timer mbedtls
|
||||
translations/
|
||||
components/fglair/ # ESPHome external component
|
||||
__init__.py # FglairHub : Component — владеет FglSession
|
||||
climate.py # FglairClimate : climate::Climate
|
||||
sensor.py switch.py select.py binary_sensor.py
|
||||
config_validation.py const.py
|
||||
translations/
|
||||
CMakeLists.txt # подключает библиотеку из корня репозитория
|
||||
# (EXTRA_COMPONENT_DIRS / relative),
|
||||
# REQUIRES lwip esp_timer mbedtls
|
||||
```
|
||||
|
||||
Ядро компилируется как статическая библиотека через CMakeLists компонента;
|
||||
платформенный слой — `platform/esp-idf` (lwip-сокеты, esp_timer, esp_random).
|
||||
Установка пользователем:
|
||||
```yaml
|
||||
external_components:
|
||||
- source: github://<user>/aircon@main
|
||||
components: [fglair]
|
||||
```
|
||||
|
||||
## 2. YAML-конфигурация
|
||||
|
||||
Базовый пример (такой же войдёт в README, с комментариями на английском;
|
||||
реальные значения — в secrets, см. §5):
|
||||
|
||||
```yaml
|
||||
external_components:
|
||||
- source: github://<user>/esphome-fglair@main
|
||||
- source: github://<user>/aircon@main
|
||||
components: [fglair]
|
||||
|
||||
fglair:
|
||||
devices:
|
||||
- id: ac_living
|
||||
ip_address: 192.0.2.3 # либо dsn + mdns: true (запрос на :10276)
|
||||
dsn: AC000W00REDACTED
|
||||
lanip_key: REDACTED-LANIP-KEY
|
||||
lanip_key_id: 62888
|
||||
template: A # A|B|F
|
||||
# port: 10275 # локальный порт сервера (default)
|
||||
# keepalive: 15s
|
||||
host: ac.local # DNS-имя (или IP); .local — mDNS :10276
|
||||
dsn: !secret ac_dsn
|
||||
lanip_key: !secret ac_lanip_key
|
||||
lanip_key_id: !secret ac_lanip_key_id
|
||||
template: A # A | B | F
|
||||
# keepalive: 15s # по умолчанию 15s
|
||||
# port: 10275 # локальный порт сервера (по умолчанию)
|
||||
|
||||
climate:
|
||||
- platform: fglair
|
||||
device_id: ac_living
|
||||
name: "Кондиционер"
|
||||
# swing: both # off|vertical|horizontal|both
|
||||
name: "Living Room AC"
|
||||
|
||||
sensor:
|
||||
- platform: fglair
|
||||
device_id: ac_living
|
||||
room_temperature: {name: "Температура в комнате"}
|
||||
error_code: {name: "Код ошибки"}
|
||||
room_temperature: { name: "Room Temperature" }
|
||||
error_code: { name: "AC Error Code" }
|
||||
|
||||
switch:
|
||||
- platform: fglair
|
||||
device_id: ac_living
|
||||
economy: {name: "Эко"}
|
||||
powerful: {name: "Мощный"}
|
||||
coil_dry: {name: "Осушка змеевика"}
|
||||
min_heat: {name: "Мин. обогрев"}
|
||||
outdoor_low_noise: {name: "Тихий наружный блок"}
|
||||
wifi_led: {name: "LED Wi-Fi"}
|
||||
economy: { name: "Economy" }
|
||||
powerful: { name: "Powerful" }
|
||||
coil_dry: { name: "Coil Dry" }
|
||||
min_heat: { name: "Minimum Heat" }
|
||||
outdoor_low_noise:{ name: "Outdoor Low Noise" }
|
||||
wifi_led: { name: "Wi-Fi LED" }
|
||||
```
|
||||
|
||||
**Откуда брать ключ**: у пользователя обычно уже есть HA-интеграция (или
|
||||
запускается `fglair-discover` CLI из репозитория ядра). HA-диагностика
|
||||
устройства показывает `lanip_key`/`lanip_key_id` — значения копируются в YAML
|
||||
вручную. Ключ статичен (зашит в модуль), автоматическая синхронизация не
|
||||
предусмотрена.
|
||||
### 2.1. `host` вместо IP (требование)
|
||||
|
||||
**Поведение при смене ключа**: ядро отвечает 412 и переходит в `key_error`;
|
||||
компонент логирует ошибку с текстом «lanip_key устарел, обновите конфиг» и
|
||||
останавливает сессию (не долбит модуль). Обновление — правка YAML вручную.
|
||||
* Валидатор — `cv.string` (имя или IP). Разрешение выполняет ядро
|
||||
(`fgl_config.host`, PLAN_CORE §3): `getaddrinfo` (lwip DNS); для имён
|
||||
`*.local` — Ayla-mDNS A-запрос на `224.0.0.251:10276` (модуль не отвечает
|
||||
на :5353 — проверено). Ретраи разрешения при потере связи, mDNS-кэш TTL.
|
||||
* В YAML планах/примерах использовать `ac.local`-стиль имён, никаких
|
||||
реальных IP.
|
||||
|
||||
## 3. Компонент `fglair` (hub, `__init__.py`)
|
||||
### 2.2. Кастомная конверсия через лямбду
|
||||
|
||||
* `FglairHub : public Component` — на каждое `device` создаёт `FglSession`
|
||||
(API ядра) при `setup()`; колбэки ядра приходят из его внутренней задачи —
|
||||
мост в main-loop ESPHome через `Component::defer()`.
|
||||
* `dump_config()`: версия ядра, состояние, статистика (re-keys, команды,
|
||||
lost-push), измеренный возраст re-key.
|
||||
* `loop()`: поллинг mailbox (транзакции из main-loop в ядро — тоже через
|
||||
mailbox ядра, ядро однопоточное внутри).
|
||||
* Зависимости: `network`; старт сессии только после `network::is_connected()`;
|
||||
при смене IP самой ESP ядро перерегистрируется само (local_reg с новым ip).
|
||||
* Доступность: таймаут watchdog 60 с без push и без успешного local_reg →
|
||||
entities в NaN/unavailable; восстановление ядром (backoff) возвращает.
|
||||
Переопределение конверсии свойства (вместо шаблонной) — передаётся в ядро
|
||||
как `fgl_conversion{custom_fn}` (PLAN_CORE §4):
|
||||
|
||||
```yaml
|
||||
fglair:
|
||||
devices:
|
||||
- id: ac_living
|
||||
host: ac.local
|
||||
# ...
|
||||
convert:
|
||||
- property: adjust_temperature
|
||||
to_display: !lambda "return x * 0.1;" # raw -> display
|
||||
from_input: !lambda "return (int32_t)(x * 10);" # display -> raw
|
||||
# range: [16, 30]
|
||||
```
|
||||
|
||||
ESPHome-лямбды компилируются в C++-функции и передаются в ядро напрямую
|
||||
(capture недоступен — если нужен контекст, использовать глобальные
|
||||
конфиг-переменные; задокументировать).
|
||||
|
||||
## 3. Компонент `fglair` (hub)
|
||||
|
||||
* `FglairHub : Component` — создаёт `FglSession` на каждое `device` в
|
||||
`setup()` (после `network::is_connected()`); колбэки ядра приходят из его
|
||||
задачи — мост в main-loop через `Component::defer()`.
|
||||
* `dump_config()`: версия ядра, состояние, статистика (re-key счётчик,
|
||||
команды, потерянные push), измеренный возраст re-key.
|
||||
* Состояния ядра → диагностика: `online/recovering/offline/key_error`
|
||||
(key_error логирует «lanip_key устарел, обновите secrets» и останавливает
|
||||
сессию — обновление только правкой YAML, см. §5).
|
||||
* Watchdog: 60 с без push и без успешного local_reg → entities NaN.
|
||||
|
||||
## 4. `climate.py`
|
||||
|
||||
* `FglairClimate : public climate::Climate, public Component`:
|
||||
* `traits()`: modes OFF/COOL/HEAT/DRY/FAN_ONLY/AUTO (фильтр по
|
||||
`device_capabilities`); fan quiet/low/medium/high/auto; swing off/vertical/
|
||||
horizontal/both; step 0.5 °C (шаблон B — 1.0 °C); min/max 16–30 °C;
|
||||
* `control(const ClimateCall&)`: все изменения вызова — в ОДИН
|
||||
`batch_begin()/batch_commit()` ядра (один local_reg notify на действие);
|
||||
OFF → `operation_mode=0`; turn_on → `operation_mode=1`;
|
||||
* `current_temperature` ← `display_temperature`; остальные значения из кэша
|
||||
ядра; push-колбэк ядра → `publish_state()`;
|
||||
* записи не эхируются (PROTOCOL.md §5.3) — optimistic update в кэше ядра,
|
||||
state публикуется сразу;
|
||||
* presets: `CLIMATE_PRESET_ECO` / `CLIMATE_PRESET_BOOST` → economy_mode /
|
||||
powerful_mode.
|
||||
* `traits()`: modes OFF/COOL/HEAT/DRY/FAN_ONLY/AUTO (фильтр по
|
||||
`device_capabilities`); fan quiet/low/medium/high/auto; swing off/vertical/
|
||||
horizontal/both; step 0.5 °C (шаблон B — 1.0 °C); min/max из шаблона/override.
|
||||
* `control(const ClimateCall&)`: все изменения вызова — в ОДИН
|
||||
`batch_begin()/batch_commit()`; OFF → `operation_mode=0`; turn_on → `=1`.
|
||||
* `current_temperature` ← `display_temperature`; значения из кэша ядра;
|
||||
push-колбэк → `publish_state()`; записи — optimistic (эха нет,
|
||||
PROTOCOL §5.3).
|
||||
* Presets: `ECO`/`BOOST` → economy_mode/powerful_mode.
|
||||
* `sensor.py`: room temp (°C, точность 0.25), error_code, op_status-флаги.
|
||||
* `switch.py`: bool-свойства. `select.py` (v2): положения заслонок.
|
||||
`binary_sensor.py`: connectivity.
|
||||
|
||||
## 5. Остальные платформы
|
||||
## 5. Откуда брать ключ (для README)
|
||||
|
||||
* `sensor.py`: `room_temperature` (°C, точность 0.25), `error_code`,
|
||||
`op_status` (text-флаги defrost/oil_recovery/pump_down/maintenance/
|
||||
check_operation/запреты — по битовой маске PROTOCOL.md §8.4).
|
||||
* `switch.py`: bool-свойства; `write_state` → `set_bool` ядра.
|
||||
* `select.py` (v2): `af_vertical_direction`/`af_horizontal_direction`, N опций
|
||||
из `af_*_num_dir`.
|
||||
* `binary_sensor.py`: `connectivity` = ONLINE.
|
||||
1. **Из Home Assistant** (если интеграция уже настроена): настройки
|
||||
устройства → диагностика — там показаны `lanip_key`, `lanip_key_id`,
|
||||
`dsn`; скопировать в `secrets.yaml`.
|
||||
2. **CLI-дискавери** (облако Ayla, без установки HA):
|
||||
```bash
|
||||
# печатает блок для secrets.yaml
|
||||
python tools/fglair-discover --region eu --email <email> --output esphome-secrets
|
||||
# ac_dsn: "AC000W00XXXXXXX"
|
||||
# ac_lanip_key: "<base64>"
|
||||
# ac_lanip_key_id: 62999
|
||||
```
|
||||
3. Существующий `config_*.json` от legacy-скрипта — поля переносятся в
|
||||
secrets вручную.
|
||||
Ключ статичен; при несовпадении `key_id` — правка secrets вручную.
|
||||
|
||||
## 6. Ограничения и требования
|
||||
## 6. README компонента (после реализации; для людей, коротко)
|
||||
|
||||
* Платы: esp32/esp32s3/esp32c3 (lwip + ≥ 25–30 КБ свободной RAM под сессию
|
||||
и буферы ядра; для c3 проверить стек задачи ядра).
|
||||
* Порт 10275 (или настраиваемый) должен быть свободен; конфликт с `api`
|
||||
(6053)/`ota` исключён.
|
||||
* **Одно устройство на ESP** в v1 (мульти-устройства — M6 ядра/FglHub).
|
||||
* Колбэки ядра — не в main-loop; мост через `defer()` обязателен.
|
||||
* OTA-обновление ESPHome поверх живой сессии: `stop()` в `on_shutdown`-
|
||||
триггере не гарантируется — слот на модуле освободится сам по таймауту
|
||||
(< 2 мин); ядро переживает это штатно (проверено на приборе).
|
||||
Разделы:
|
||||
1. **Quick start** — минимальный YAML (§2) с комментариями на английском;
|
||||
все чувствительные значения через `!secret`.
|
||||
2. **Where to get the key** — §5 (HA-диагностика / fglair-discover CLI /
|
||||
legacy-конфиг).
|
||||
3. **Custom conversions** — пример с лямбдами (§2.2).
|
||||
4. **Advanced** — пример «с действиями и событиями»: переключение режимов по
|
||||
внешнему триггеру + вывод данных на дисплей. Схема примера (LVGL-часть —
|
||||
с пропусками несущественных секций, помеченными `# ...`):
|
||||
```yaml
|
||||
# External trigger: switch the AC to powerful cool mode on demand
|
||||
binary_sensor:
|
||||
- platform: gpio
|
||||
id: hot_day_trigger
|
||||
on_press:
|
||||
then:
|
||||
- climate.control:
|
||||
id: ac_living
|
||||
hvac_mode: COOL
|
||||
preset: BOOST
|
||||
- logger.log: "Hot day: powerful cooling enabled"
|
||||
|
||||
## 7. Тесты и приёмка
|
||||
schedule: # rotate operation modes by time of day
|
||||
- platform: time
|
||||
on_time:
|
||||
- hours: 7
|
||||
then:
|
||||
- climate.control: { id: ac_living, hvac_mode: AUTO }
|
||||
|
||||
1. CI: `esphome compile` для тестовых конфигов (esp32-idf, esp32s3-idf).
|
||||
2. Mock-модуль ядра + локальная сборка компонента — smoke: старт, online,
|
||||
одна запись, публикация state.
|
||||
3. На приборе: чек-лист M5 ядра + OTA-ребут поверх сессии + 24 ч uptime
|
||||
под управлением из HA через native API.
|
||||
4. Приёмка: 24 ч без рассинхронов; параллельно телефон (2 слота); изменение
|
||||
с пульта отражается в HA < 1 с.
|
||||
display: # LVGL dashboard (relevant fragments only)
|
||||
lvgl:
|
||||
# ... widget definitions omitted ...
|
||||
- label:
|
||||
id: room_temp_label
|
||||
text:
|
||||
format: "%.1f°C"
|
||||
# bound via lambda to id(ac_living).current_temperature
|
||||
- label:
|
||||
id: mode_label
|
||||
# ... omitted ...
|
||||
# bound to id(ac_living).mode via lambda
|
||||
script:
|
||||
- id: push_mode_to_display
|
||||
# called on climate state change (on_state trigger), omitted
|
||||
```
|
||||
5. **Troubleshooting** — 503 (оба слота заняты), key_error, «KE без
|
||||
активации» → подождать/перезапустить.
|
||||
|
||||
## 8. Этапы
|
||||
## 7. Ограничения
|
||||
|
||||
* esp32/esp32s3/esp32c3; ≥25–30 КБ свободной RAM; порт 10275 свободен.
|
||||
* Одно устройство на ESP в v1 (FglHub — M5 ядра).
|
||||
* OTA-ребут поверх живой сессии: слот освободится сам (<2 мин), ядро
|
||||
переживает штатно (проверено).
|
||||
|
||||
## 8. Приёмка (полуавтоматическая, `tests/acceptance/`)
|
||||
|
||||
Топология: один кондиционер, HA-интеграция (на сервере HA) + ESPHome-устройство
|
||||
(ESP32) — занимают оба слота модуля. Топология обязательна для приёмки и
|
||||
заодно проверяет совместное владение.
|
||||
|
||||
Скрипт `tests/acceptance/test_esphome_ha.py` (python):
|
||||
* **ESPHome-сторона**: официальный `aioesphomeapi` — подключение к устройству
|
||||
по имени, `subscribe_states`, `climate_command(...)` для изменений.
|
||||
* **HA-сторона**: **long-lived access token** (Создаётся пользователем:
|
||||
Profile → Security → Long-lived access tokens) + REST API
|
||||
(`/api/services/climate/set_*`, `/api/states/<entity_id>`) — стандартный,
|
||||
документированный механизм, отдельной авторизации не требуется.
|
||||
* **Режим quick (~5 мин)**: матрица {параметр: hvac_mode, target_temp,
|
||||
fan_mode, swing} × {направление: HA→ESP, ESP→HA} × {одиночное изменение,
|
||||
burst из 10}. Каждый шаг: изменение на стороне A → ожидание отражения на
|
||||
стороне B (таймаут 10 с) → возврат → проверка возврата. Отдельно: после
|
||||
burst — проверка согласованности финальных состояний и отсутствия ошибок
|
||||
в логах обеих сторон.
|
||||
* **Режим `--long` (24 ч)**: раз в час — одно псевдослучайное изменение
|
||||
(ротация по списку параметров), проверка на другой стороне, возврат,
|
||||
проверка. CSV-лог + итоговый отчёт (успехи/провалы/задержки).
|
||||
* Запуск вручную; входит в релизный чек-лист компонента (E4).
|
||||
|
||||
## 9. Этапы
|
||||
|
||||
| # | Содержимое |
|
||||
|---|-----------|
|
||||
| E1 | каркас external component, компиляция ядра (esp-idf), hub + connectivity |
|
||||
| E2 | climate (mode/fan/temp/swing, batch-запись, optimistic state) |
|
||||
| E3 | sensor/switch, capabilities-фильтры, translations, README с YAML |
|
||||
| E4 | select (заслонки), mdns-опция (:10276), CI, релиз |
|
||||
| E1 | каркас компонента, компиляция ядра (esp-idf), hub + connectivity |
|
||||
| E2 | climate (mode/fan/temp/swing, batch, optimistic), host-резолвер |
|
||||
| E3 | sensor/switch, capabilities, кастомные конверсии (лямбды), translations |
|
||||
| E4 | README (§6), скрипт приёмки (§8), CI, релиз |
|
||||
|
||||
@@ -1,146 +1,158 @@
|
||||
# План: интеграция для Home Assistant (`fglair` custom component)
|
||||
# План: интеграция Home Assistant (`custom_components/fglair`)
|
||||
|
||||
Аудитория — агенты-реализаторы. Протокол — `docs/PROTOCOL.md`, библиотека —
|
||||
`docs/PLAN_CORE_LIBRARY.md` (`fglair-core`, C++20).
|
||||
Аудитория — агенты-реализаторы. Протокол — `docs/PROTOCOL.md`; ядро —
|
||||
`docs/PLAN_CORE_LIBRARY.md` (`fgl-aircon`, C++20, монорепо: библиотека в
|
||||
корне, компонент HA здесь).
|
||||
|
||||
## 1. Архитектура
|
||||
|
||||
```
|
||||
Home Assistant (custom component `fglair`)
|
||||
│ использует python-пакет pyfglair (cffi-bindings к libfglair-core.so)
|
||||
│ использует python-пакет pyfglair (cffi-bindings к libfgl-aircon.so)
|
||||
▼
|
||||
pyfglair (wheel: linux x86_64/aarch64; cffi; собирает fglair-core через cmake)
|
||||
pyfglair (wheel: linux x86_64/aarch64; cffi; собирает библиотеку из корня
|
||||
репозитория через cmake)
|
||||
│
|
||||
▼
|
||||
fglair-core (C++) ←—— mock-тесты; тот же код, что и на ESP32 (ESP-IDF)
|
||||
fgl-aircon (C++, корень монорепо) ←—— тот же код, что и на ESP32 (ESP-IDF)
|
||||
```
|
||||
|
||||
Компонент — тонкий: переводит API библиотеки в сущности HA. Вся протокольная
|
||||
логика (сессия, шифрование, pacing, re-key, восстановление) — в C-ядре.
|
||||
|
||||
Обоснование: пользователь требует переиспользования библиотеки из п.2;
|
||||
cffi-wheel — рабочий путь (HA-контейнеры: x86_64/aarch64 debian; cibuildwheel
|
||||
покрывает). Fallback (если сборка wheel станет блокером): libfglair собирается
|
||||
в docker при старте интеграции один раз (dev-mode) — не рекомендуется для
|
||||
продакшна; чисто-Python повторная реализация протокола — запрещена (дубль
|
||||
логики, расползание с ESP32-веткой).
|
||||
Обоснование: переиспользование библиотеки (требование владельца); cffi-wheel
|
||||
— рабочий путь (HA-контейнеры x86_64/aarch64 debian; cibuildwheel).
|
||||
Fallback, если сборка wheel станет блокером: сборка .so при старте в docker
|
||||
(dev-режим). Чисто-Python повторная реализация протокола — запрещена.
|
||||
|
||||
## 2. Состав репозиториев
|
||||
## 2. Состав
|
||||
|
||||
1. **`pyfglair`** (python-пакет):
|
||||
* `pyfglair/_corebuild.py` — cmake-сборка при упаковке wheel;
|
||||
* `pyfglair/_cffi.py` — cffi-декларации поверх `include/fgl/c_api.h`
|
||||
(extern "C"-шейм ядра);
|
||||
* `pyfglair/session.py` — pythonic-обёртка: `Session(cfg)`, колбэки ядра
|
||||
мостятся в `asyncio` через `loop.call_soon_threadsafe`;
|
||||
* `pyfglair/provision.py` — облачный discovery (перенос `docs/legacy/aircon/discovery.py`
|
||||
на современный aiohttp): вход e-mail/пароль/регион → dsn, ip, oem_model,
|
||||
lanip_key, lanip_key_id;
|
||||
* CLI: `python -m pyfglair discover` / `python -m pyfglair monitor`.
|
||||
2. **`ha-fglair`** (custom component, HACS-совместимый):
|
||||
* `custom_components/fglair/…` (см. §4);
|
||||
* `manifest.json`: `requirements: ["pyfglair>=1.0.0"]`, `domain: fglair`,
|
||||
`config_flow: true`, `iot_class: local_push`.
|
||||
* `pyfglair/_cffi.py` — cffi-декларации поверх `include/fgl-aircon/c_api.h`;
|
||||
* `pyfglair/session.py` — обёртка `Session(cfg)`; колбэки ядра → asyncio
|
||||
через `loop.call_soon_threadsafe`;
|
||||
* `pyfglair/templates.py` — интроспекция шаблонов через cffi-вызовы
|
||||
`fgl_template_info_get` / `fgl_convert_to_display` (для превью в
|
||||
config flow; единый источник данных — C-таблицы, дублей нет);
|
||||
* `pyfglair/provision.py` — облачный discovery (перенос логики
|
||||
`docs/legacy/aircon/discovery.py` на современный aiohttp): e-mail/
|
||||
пароль/регион → dsn, host/ip, oem_model, lanip_key, lanip_key_id;
|
||||
* CLI: `python -m pyfglair discover` / `monitor` / `--output esphome-secrets`
|
||||
(печать блока для secrets.yaml ESPHome).
|
||||
2. **`custom_components/fglair/`**:
|
||||
```
|
||||
manifest.json # requirements: ["pyfglair>=1.0.0"], config_flow: true
|
||||
config_flow.py # flow + options + repair
|
||||
fglair_client.py # фоновый поток с FglSession
|
||||
coordinator.py # push-driven coordinator
|
||||
climate.py sensor.py switch.py select.py binary_sensor.py
|
||||
diagnostics.py # lanip_key/key_id/dsn видны для копирования в ESPHome
|
||||
translations/{en,ru}.json
|
||||
```
|
||||
|
||||
## 3. Конфигурация (config flow)
|
||||
## 3. Config flow
|
||||
|
||||
Вариант A — облачный (удобный):
|
||||
1. Пользователь вводит e-mail/пароль FGLair + регион EU/US/CN.
|
||||
2. Интеграция через `pyfglair.provision` получает список устройств
|
||||
(name, model, ip, dsn, lanip_key, lanip_key_id) и показывает их.
|
||||
3. Выбор устройства → `ConfigEntry` (те же поля, что `config_kata.json`)
|
||||
→ шаг «пробное подключение» (старт сессии, ожидание ONLINE ≤ 10 с).
|
||||
Шаг 1 — **подключение** (один из вариантов):
|
||||
* A (облачный): e-mail/пароль FGLair + регион → список устройств
|
||||
(name, model, host) → выбор.
|
||||
* B (ручной): host/dsn/lanip_key/lanip_key_id по полям, либо импорт
|
||||
`config_*.json` (миграция с legacy).
|
||||
|
||||
Вариант B — ручной: ввод ip/dsn/lanip_key/lanip_key_id по полям или импорт
|
||||
существующего `config_*.json` (миграция с legacy-скрипта).
|
||||
Шаг 2 — **пробное подключение**: старт сессии, ожидание ONLINE ≤10 с,
|
||||
чтение базовых свойств. Ошибка → назад с сообщением.
|
||||
|
||||
Ключ считается **статичным** (см. PLAN_CORE_LIBRARY §8): облако используется
|
||||
только здесь, при настройке. В рантайме — никогда.
|
||||
Шаг 3 — **выбор шаблона С ПРЕВЬЮ РЕЗУЛЬТАТОВ** (требование): после выбора
|
||||
шаблона (обычно определён по oem_model автоматически) — форма-предпросмотр
|
||||
рассчитанных значений через `pyfglair.templates` (вызовы в C-ядро):
|
||||
|
||||
Repair-флоу (редкий, теоретический):
|
||||
* Состояние ядра `key_error` (несовпадение `key_id`) → repair «Ключ устройства
|
||||
изменён — перепровижинируйте». Повторный вход в облако по требованию
|
||||
пользователя, обновление полей ConfigEntry. Никаких автоматических
|
||||
перечитываний облака.
|
||||
| Поле превью | Пример значения |
|
||||
|---|---|
|
||||
| hvac-режимы | off, cool, dry, fan, heat, auto (operation_mode 0–6) |
|
||||
| fan-режимы | quiet/low/medium/high/auto (0–4) |
|
||||
| Диапазон уставки | 16.0–30.0 °C, шаг 0.5 (adjust_temperature raw 160–300, ×0.1) |
|
||||
| Текущая температура | display_temperature 7000 → 20.0 °C |
|
||||
| Заслонки | vertical 0–4 (af_vertical_num_dir), horizontal 0–6 |
|
||||
| Битмаска capabilities | heat, cool, economy, powerful, min_heat, swing … |
|
||||
|
||||
**Диагностика**: Device-страница HA показывает `lanip_key`, `lanip_key_id`,
|
||||
`dsn`, ip — чтобы пользователь мог скопировать их в конфиг ESPHome (см.
|
||||
PLAN_ESPHOME). В redacted-дамп lanip_key маскируется, в полном — виден.
|
||||
Пользователь оценивает корректность (сверяет с приложением FGLair) и
|
||||
подтверждает → создаётся `ConfigEntry`. При расхождении — возможность
|
||||
выбрать другой шаблон или задать конверсию коэффициентами (linear:
|
||||
num/den/offset) и диапазон вручную на этом же шаге.
|
||||
|
||||
## 4. Структура компонента
|
||||
Repair (теоретический): `key_error` → «Ключ устройства изменён —
|
||||
перепровижинируйте» (повторный облако-вход по требованию). Облако в рантайме
|
||||
не используется, ключ статичен.
|
||||
|
||||
```
|
||||
custom_components/fglair/
|
||||
manifest.json
|
||||
config_flow.py # flow + options flow + repair
|
||||
const.py
|
||||
fglair_client.py # FglairClient: владеет FglSession (фоновый поток)
|
||||
coordinator.py # push-driven coordinator (polling-интервал только watchdog)
|
||||
climate.py # ClimateEntity
|
||||
sensor.py # комнатная температура, error_code, op_status-флаги
|
||||
switch.py # economy, powerful, coil_dry, min_heat, outdoor_low_noise,
|
||||
# human_det_auto_save, wifi_led, indoor_fan_control
|
||||
select.py # af_vertical/horizontal_direction (если N>1), demand_control
|
||||
binary_sensor.py # connectivity
|
||||
diagnostics.py
|
||||
translations/{en,ru,...}.json
|
||||
```
|
||||
**Диагностика**: device-страница показывает `dsn`, `lanip_key`, `lanip_key_id`,
|
||||
`host` — источник для копирования в secrets ESPHome. В redacted-дампе ключ
|
||||
маскируется.
|
||||
|
||||
## 5. Маппинг сущностей (шаблон A; B/F — по capabilities)
|
||||
## 4. Сущности
|
||||
|
||||
**Climate**:
|
||||
* HVAC: `OFF`↔`operation_mode=0`; `COOL/HEAT/DRY/FAN_ONLY/AUTO`↔3/6/4/5/2;
|
||||
`turn_on` → `operation_mode=1`.
|
||||
* `target_temperature` ↔ `adjust_temperature` (×0.1 °C, шаг 0.5 °C);
|
||||
* `current_temperature` ← `display_temperature` ((v−5000)/100);
|
||||
* `fan_mode` ↔ `fan_speed` (quiet/low/medium/high/auto);
|
||||
* `swing_mode` ↔ `af_vertical_swing`+`af_horizontal_swing` (off/vertical/
|
||||
horizontal/both);
|
||||
* presets: `ECO`→economy_mode, `BOOST`→powerful_mode (взаимоисключающие);
|
||||
* capabilities из `device_capabilities` скрывают недоступные режимы.
|
||||
Как раньше (climate: hvac/fan/swing/preset ECO-BOOST; current_temperature ←
|
||||
display_temperature; sensors: room temp, error_code, op_status-флаги;
|
||||
switch: economy/powerful/coil_dry/min_heat/outdoor_low_noise/
|
||||
human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки,
|
||||
demand_control; binary_sensor: connectivity). capabilities фильтруют
|
||||
режимы/пресеты. Записи — один `batch_commit()` на действие пользователя.
|
||||
|
||||
**Sensors**: room temp, error_code (с текстами), op_status (флаги defrost /
|
||||
oil recovery / pump down / maintenance / check operation / запреты).
|
||||
**Binary sensor**: connectivity (ONLINE). **Switch/Select** — по списку §4.
|
||||
## 5. Runtime
|
||||
|
||||
Записи идут batch'ем на действие пользователя (одно `batch_commit()` на вызов
|
||||
`set_temperature`/`set_hvac_mode`/…) — ядро само склеит в один local_reg notify.
|
||||
Один `FglairClient` на `ConfigEntry` (daemon-thread, сессия ядра); колбэки →
|
||||
asyncio → push-coordinator. Состояния: `online` → available;
|
||||
`recovering` → доступны (последние значения) + diagnostic-сенсор;
|
||||
`offline` → unavailable; `key_error` → unavailable + repair. Keep-alive 15 с
|
||||
(самолечение десинхрона ≤15 с). Выгрузка: `stop()` (delete_session).
|
||||
|
||||
## 6. Runtime-модель
|
||||
## 6. README компонента (после реализации; для людей, коротко)
|
||||
|
||||
* Один `FglairClient` на `ConfigEntry`: фоновый daemon-thread с сессией ядра;
|
||||
колбэки → `asyncio` → coordinator push-update.
|
||||
* Состояния ядра транслируются: `online` → entities available; `recovering` —
|
||||
доступны (последние значения), помечено diagnostic-сенсором; `offline` →
|
||||
unavailable; `key_error` → unavailable + repair.
|
||||
* Keep-alive/rotация ключей/переподключение — полностью в ядре (15 с;
|
||||
самолечение десинхрона ≤15 с — PROTOCOL.md §4.4).
|
||||
* Выгрузка: `stop()` (delete_session, освобождает слот на модуле).
|
||||
Структура (`screenshots/step-N.png` — заглушки-плейсхолдеры, владелец заменит
|
||||
реальными скриншотами; рядом с каждой — описание что должно быть видно):
|
||||
|
||||
## 7. Требования к надёжности (acceptance)
|
||||
1. **Установка через HACS**:
|
||||
* HACS → ⋮ → Custom repositories → URL репозитория, категория
|
||||
Integration → Add. Скриншот: диалог добавления custom repository с
|
||||
заполненным URL и выбранной категорией Integration.
|
||||
* FGLair → Download → перезапуск HA. Скриншот: страница загрузки
|
||||
интеграции с кнопкой Download (версия видна).
|
||||
2. **Добавление устройства**: Settings → Devices & Services → Add
|
||||
Integration → «FGLair». Скриншот: диалог поиска интеграции с введённым
|
||||
«FGLair» и выделенным результатом.
|
||||
3. **Вход в облако** (шаг 1A): e-mail/пароль/регион. Скриншот: форма с
|
||||
заполненными регионом EU и e-mail (пароль скрыт).
|
||||
4. **Выбор устройства**: список найденных кондиционеров. Скриншот: список
|
||||
с одним устройством (имя, модель, host).
|
||||
5. **Проверка шаблона с превью** (шаг 3): Скриншот: форма превью — таблица
|
||||
рассчитанных значений (режимы, диапазон температур, пример конверсии
|
||||
температуры), кнопки Confirm/Change template.
|
||||
6. **Готово**: карточка устройства со списком сущностей. Скриншот: страница
|
||||
устройства с созданными climate/sensor/switch сущностями.
|
||||
7. **Где взять ключ для ESPHome**: диагностика устройства. Скриншот: страница
|
||||
Diagnostics с полями dsn/lanip_key/lanip_key_id.
|
||||
8. Troubleshooting: 503 (оба слота заняты — телефон+ESP?), key_error,
|
||||
недоступность.
|
||||
|
||||
1. 24 ч непрерывной работы: 0 рассинхронов; в логе ядра — штатные re-key
|
||||
каждые 45–60 с; сущности обновляются < 1 с после изменений с пульта.
|
||||
2. Параллельная работа с приложением на телефоне: обе сессии живут (2 слота);
|
||||
без взаимных сбоев.
|
||||
3. Вкл/выкл питания модуля: восстановление ≤ 120 с (backoff).
|
||||
4. Burst 10 изменений уставки из UI: ≤ 2 local_reg notify.
|
||||
5. `key_error` (симуляция смены lanip_key_id): repair отрабатывает.
|
||||
6. Перезапуск HA: корректный delete_session, повторная регистрация.
|
||||
## 7. Приёмка (полуавтоматическая, `tests/acceptance/test_esphome_ha.py`)
|
||||
|
||||
## 8. Тесты
|
||||
Совместно с ESPHome-компонентом (топология и детали — PLAN_ESPHOME §8).
|
||||
Со стороны HA скрипт использует **long-lived access token** (профиль →
|
||||
Security → Long-lived access tokens) и REST API: вызов сервисов
|
||||
`/api/services/climate/set_temperature|set_hvac_mode|set_fan_mode|set_swing_mode`
|
||||
и чтение `/api/states/<entity_id>`. Возможность подтверждена: это
|
||||
стандартный документированный механизм HA REST API, отдельная авторизация
|
||||
(OAuth-флоу) не нужна — пользователь просто создаёт токен и передаёт
|
||||
скрипту (`--ha-url`, `--ha-token`).
|
||||
Режимы: quick (матрица burst/не-burst, обе стороны, с возвратом) и `--long`
|
||||
(24 ч, ежечасное изменение с проверкой и возвратом, CSV-отчёт). Скрипт НЕ
|
||||
открывает собственную сессию к кондиционеру (оба слота заняты HA+ESP).
|
||||
|
||||
* `pytest` + mock `pyfglair` (fake session, scripted callbacks): flow, entities,
|
||||
repair, unload.
|
||||
* Интеграционный тест с mock-модулем (`test/integration/mock_ac.py` ядра) через
|
||||
настоящий wheel: полный цикл в CI.
|
||||
* Ручной чек-лист на реальном приборе (совпадает с M5 ядра).
|
||||
|
||||
## 9. Этапы
|
||||
## 8. Этапы
|
||||
|
||||
| # | Содержимое |
|
||||
|---|-----------|
|
||||
| H1 | wheel `pyfglair`: сборка, cffi-bindings, обёртка Session, CLI discover/monitor |
|
||||
| H2 | компонент: manifest, config_flow (облако + ручной + импорт), client/coordinator, climate |
|
||||
| H3 | sensor/switch/select/binary_sensor, capabilities, translations, диагностика с lanip_key |
|
||||
| H4 | repair-флоу, unload, тесты, публикация в HACS (custom) |
|
||||
| H1 | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
|
||||
| H2 | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
|
||||
| H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
|
||||
| H4 | repair, диагностика (ключ для ESPHome), translations |
|
||||
| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз |
|
||||
|
||||
@@ -43,10 +43,10 @@ SDK `com.aylanetworks.aylasdk`, см. `AylaLanModule.java`, `AylaEncryption.java
|
||||
`lan_ip` каждого устройства. Плюс `GET /apiv1/dsns/<DSN>/lan.json` отдаёт
|
||||
`{ "lanip": { "lanip_key": ..., "lanip_key_id": ..., "keepAlive": ..., "autoSync": ... } }`. **[APK]**
|
||||
2. **mDNS**: приложение опрашивает A-запись `<DSN>.local` (например
|
||||
`AC000W00REDACTED.local`), отправляя DNS-query на `224.0.0.251:5353` **и на
|
||||
`<DSN>.local`, например `AC000W00ABCD1234.local`), отправляя DNS-query на `224.0.0.251:5353` **и на
|
||||
`224.0.0.251:10276`** (нестандартный порт Ayla). **[ПРОВЕРЕНО НА ПРИБОРЕ:
|
||||
модуль отвечает ТОЛЬКО на :10276, на :5353 — нет.** A-ответ, TTL 10,
|
||||
имя `AC000W00REDACTED.local` → IP модуля. Проба: `tools/probe_mdns.py`.]
|
||||
имя `<DSN>.local` → IP модуля. Проба: `tools/probe_mdns.py`.]
|
||||
3. **Кэш** приложения хранит последние lan_ip/lanip_key. **[APK]**
|
||||
|
||||
Для библиотеки минимумом является статическая конфигурация вида `config_kata.json`
|
||||
@@ -61,7 +61,7 @@ SDK `com.aylanetworks.aylasdk`, см. `AylaLanModule.java`, `AylaEncryption.java
|
||||
|
||||
```
|
||||
POST /local_lan/key_exchange.json
|
||||
{"key_exchange":{"ver":1,"proto":1,"key_id":62888,"random_1":"<16 алфанум. симв.>","time_1":<int>,"sec":""}}
|
||||
{"key_exchange":{"ver":1,"proto":1,"key_id":62999,"random_1":"<16 алфанум. симв.>","time_1":<int>,"sec":""}}
|
||||
```
|
||||
|
||||
* `ver` и `proto` обязаны быть `1` (AES-256-CBC + HMAC-SHA256). Иначе — **426 Upgrade Required**. **[APK]**
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
# aircon / FGLair local control — документация
|
||||
# aircon / FGLair local control
|
||||
|
||||
Реконструкция LAN-протокола FGLair (Fujitsu General, платформа Ayla) и планы
|
||||
реализации стека локального управления кондиционером.
|
||||
монорепозитория стека локального управления кондиционером: базовая C++
|
||||
библиотека + интеграции ESPHome и Home Assistant.
|
||||
|
||||
## Состав
|
||||
## Состав репозитория (целевая структура)
|
||||
|
||||
```
|
||||
CMakeLists.txt # базовая библиотека (корень)
|
||||
include/fgl-aircon/ # публичный API (уровень aircon: конверсии, шаблоны)
|
||||
src/ayla/ # протокол Ayla LAN + главный цикл сообщений
|
||||
platform/{posix,esp-idf}/ # платформенный слой
|
||||
src/aircon/ # конверсии, шаблоны A/B/F, реализация API
|
||||
third_party/jsmn/ # вендоренный JSON-парсер (MIT)
|
||||
components/fglair/ # ESPHome external component
|
||||
custom_components/fglair/ # Home Assistant custom component
|
||||
tests/{ayla,aircon}/ # тесты протокола / конверсий и шаблонов
|
||||
tests/acceptance/ # полуавтоматическая приёмка ESPHome<->HA
|
||||
tools/ # probe_reference.py, probe_mdns.py, fglair-discover
|
||||
docs/ # документация (ниже) + материалы анализа
|
||||
```
|
||||
|
||||
## Документация
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|-----------|
|
||||
| `PROTOCOL.md` | Спецификация LAN-протокола: шифрование, эндпоинты, машина состояний, тайминги, свойства FGLair. Для людей и агентов. Факты помечены `[APK]` / `[LEGACY]` / `[ПРОВЕРЕНО НА ПРИБОРЕ]` / `[HYP]`. |
|
||||
| `LEGACY_ANALYSIS.md` | Разбор legacy-скрипта: что верно, баги, причины «рассинхронизации ключей» и перегрузки модуля. |
|
||||
| `PLAN_CORE_LIBRARY.md` | План C++20-библиотеки `fglair-core` (Linux + ESP-IDF). |
|
||||
| `PLAN_HOME_ASSISTANT.md` | План HA-интеграции (`pyfglair` wheel + custom component). |
|
||||
| `PLAN_ESPHOME.md` | План external component для ESPHome (только ESP-IDF framework). |
|
||||
| `../tools/probe_mdns.py` | mDNS-проба (`<DSN>.local`, порт 10276). |
|
||||
| `legacy/` | Изменённый legacy-скрипт (форк [gyro-labs/AirCon](https://github.com/gyro-labs/AirCon) / hisense_ac): `main.py` + пакет `aircon/` + рабочий конфиг `config_kata.json`. |
|
||||
| `apk/` | Материалы анализа APK FGLair 3.4.3 (`manifest.json`; сами .apk лежат локально, не версионируются). |
|
||||
| `PLAN_CORE_LIBRARY.md` | План C++20-библиотеки `fgl-aircon` (Linux + ESP-IDF): слои ayla/aircon, конверсии, оценка httpd/json-библиотек, монорепо-структура. |
|
||||
| `PLAN_HOME_ASSISTANT.md` | План HA-интеграции: pyfglair (cffi wheel), config flow с превью шаблона, HACS-README со скриншотами. |
|
||||
| `PLAN_ESPHOME.md` | План ESPHome-компонента: host+DNS, секреты, кастомные конверсии-лямбды, advanced-пример, приёмка. |
|
||||
| `legacy/` | Изменённый legacy-скрипт (форк [gyro-labs/AirCon](https://github.com/gyro-labs/AirCon) / hisense_ac): `main.py` + пакет `aircon/`. Локальный `config_kata.json` не версионируется (содержит lanip_key). |
|
||||
| `apk/` | Материалы анализа APK FGLair 3.4.3 (`manifest.json`; .apk-бинарники лежат локально, не версионируются). |
|
||||
|
||||
## Краткая выжимка протокола
|
||||
|
||||
@@ -39,21 +56,35 @@
|
||||
|
||||
## Ключевые решения (по уточнениям владельца)
|
||||
|
||||
* Монорепозиторий: библиотека в корне, `components/fglair` (ESPHome),
|
||||
`custom_components/fglair` (HA); тесты зеркалят слои (`tests/ayla`,
|
||||
`tests/aircon`); библиотека логически разделена на `src/ayla` (протокол)
|
||||
и `src/aircon` (конверсии/шаблоны/API), публичный интерфейс —
|
||||
`include/fgl-aircon/`.
|
||||
* Конверсии свойств задаются шаблоном по умолчанию, коэффициентами
|
||||
(linear) или функцией-указателем (ESPHome — лямбды, HA — коэффициенты
|
||||
+ превью рассчитанных значений при настройке).
|
||||
* `lanip_key` **статичен** (зашит в модуль; за 5 лет ротаций не было).
|
||||
Облако используется только для первового provisioning'а (HA config flow или
|
||||
CLI `fglair-discover`); при несовпадении `key_id` — устойчивая ошибка,
|
||||
лечение правкой конфига вручную. Для ESPHome ключ копируется из диагностики
|
||||
HA или получается CLI-той.
|
||||
* ESP-IDF везде (Arduino-фреймворк ESPHome не поддерживаем), язык ядра — C++20
|
||||
(без исключений/RTTI/heap после init), public API — C++-классы + extern "C"
|
||||
шейм для cffi-bindings HA.
|
||||
Облако — только provisioning (HA config flow, CLI `fglair-discover`);
|
||||
для ESPHome ключ копируется из диагностики HA или CLI. При несовпадении
|
||||
`key_id` — устойчивая ошибка, лечение правкой конфига вручную.
|
||||
* ESPHome: везде ESP-IDF framework, подключение по `host` (DNS/mDNS), все
|
||||
чувствительные значения — через `!secret`.
|
||||
* HTTP/JSON: собственный мини-httpd/httpc на BSD-сокетах (одна реализация
|
||||
для lwip/posix) + вендоренный jsmn; esp_http_server/cJSON/ArduinoJson
|
||||
отвергнуты (обоснование — PLAN_CORE §7). Conan не нужен.
|
||||
* Приёмка: полуавтоматический скрипт `tests/acceptance/` (HA long-lived
|
||||
token + REST, ESPHome через aioesphomeapi; quick/burst и 24-часовой
|
||||
режимы).
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
1. `fglair-core` (M0–M5) — ядро, mock-тесты, эталон уже проверен на приборе.
|
||||
2. `pyfglair` + HA-интеграция (H1–H4) — параллельно с E1–E2.
|
||||
1. `fgl-aircon` (M0–M4) — ядро (ayla → aircon), mock-тесты; эталон
|
||||
`tools/probe_reference.py` уже проверен на приборе.
|
||||
2. `pyfglair` + HA-интеграция (H1–H5) — параллельно с E1–E2.
|
||||
3. ESPHome-компонент (E1–E4).
|
||||
4. Уточнение оставшихся неизвестных (PROTOCOL.md §10) по мере эксплуатации.
|
||||
4. Приёмочные прогоны (quick + 24 ч), README компонентов.
|
||||
5. Уточнение оставшихся неизвестных (PROTOCOL.md §10) по мере эксплуатации.
|
||||
|
||||
## Источники
|
||||
|
||||
@@ -62,5 +93,4 @@
|
||||
`com.cafbit.netlib.dns.NetThread`, JS-бандл `assets/www/dist/build.js`.
|
||||
* Legacy-скрипт (`legacy/`) — изменённый форк gyro-labs/AirCon (hisense_ac).
|
||||
* Живые эксперименты на AP-WC1E (сентябрь 2026): сессии, re-key, 401/400,
|
||||
слоты/503, delete_session, записи, mDNS. Пробы: `tools/probe_*.py`
|
||||
(история — сессия анализа; рабочие артефакты оставлены в tools/).
|
||||
слоты/503, delete_session, записи, mDNS. Рабочие артефакты — `tools/`.
|
||||
|
||||
Reference in New Issue
Block a user