docs: корректировки планов — монорепо, слои ayla/aircon, конверсии, приёмка

- PLAN_CORE: монорепозиторий (CMakeLists в корне; components/fglair,
  custom_components/fglair, include/fgl-aircon, src/{ayla,aircon},
  src/ayla/platform); логическое разделение ay­la (протокол+цикл) /
  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:
2026-09-21 13:20:29 +03:00
parent 790cee3c78
commit b21817ab9f
6 changed files with 625 additions and 453 deletions

View File

@@ -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, релиз |