diff --git a/docs/PLAN_CORE_LIBRARY.md b/docs/PLAN_CORE_LIBRARY.md index 33ca232..b656a56 100644 --- a/docs/PLAN_CORE_LIBRARY.md +++ b/docs/PLAN_CORE_LIBRARY.md @@ -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=` с `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=` с `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 + промежуточно). diff --git a/docs/PLAN_ESPHOME.md b/docs/PLAN_ESPHOME.md index ca77d41..59e66d9 100644 --- a/docs/PLAN_ESPHOME.md +++ b/docs/PLAN_ESPHOME.md @@ -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:///aircon@main + components: [fglair] +``` ## 2. YAML-конфигурация +Базовый пример (такой же войдёт в README, с комментариями на английском; +реальные значения — в secrets, см. §5): + ```yaml external_components: - - source: github:///esphome-fglair@main + - source: github:///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 --output esphome-secrets + # ac_dsn: "AC000W00XXXXXXX" + # ac_lanip_key: "" + # 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/`) — стандартный, + документированный механизм, отдельной авторизации не требуется. +* **Режим 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, релиз | diff --git a/docs/PLAN_HOME_ASSISTANT.md b/docs/PLAN_HOME_ASSISTANT.md index 35efb02..eccd41b 100644 --- a/docs/PLAN_HOME_ASSISTANT.md +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -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/`. Возможность подтверждена: это +стандартный документированный механизм 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-релиз | diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index f7f8341..c2a229c 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -43,10 +43,10 @@ SDK `com.aylanetworks.aylasdk`, см. `AylaLanModule.java`, `AylaEncryption.java `lan_ip` каждого устройства. Плюс `GET /apiv1/dsns//lan.json` отдаёт `{ "lanip": { "lanip_key": ..., "lanip_key_id": ..., "keepAlive": ..., "autoSync": ... } }`. **[APK]** 2. **mDNS**: приложение опрашивает A-запись `.local` (например - `AC000W00REDACTED.local`), отправляя DNS-query на `224.0.0.251:5353` **и на + `.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`.] + имя `.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":,"sec":""}} +{"key_exchange":{"ver":1,"proto":1,"key_id":62999,"random_1":"<16 алфанум. симв.>","time_1":,"sec":""}} ``` * `ver` и `proto` обязаны быть `1` (AES-256-CBC + HMAC-SHA256). Иначе — **426 Upgrade Required**. **[APK]** diff --git a/docs/README.md b/docs/README.md index 4ba46f7..6cc21da 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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-проба (`.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/`. diff --git a/tools/probe_mdns.py b/tools/probe_mdns.py index f1aad64..b7773f2 100644 --- a/tools/probe_mdns.py +++ b/tools/probe_mdns.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """Пассивная mDNS-проба модуля кондиционера: A-запрос .local -на 224.0.0.251:5353 и :10276 (нестандартный порт Ayla из APK).""" -import socket, struct, sys, time +на 224.0.0.251:5353 и :10276 (нестандартный порт Ayla из APK). +Модуль отвечает ТОЛЬКО на :10276 (проверено на приборе). -DSN = "AC000W00REDACTED" -HOST = DSN + ".local" +Использование: python probe_mdns.py (например AC000W00ABCD1234)""" +import socket, struct, sys, time def qname(name): out = b"" @@ -13,7 +13,6 @@ def qname(name): return out + b"\x00" def query_packet(txid): - # standard query, RD=1, one question: A, class IN header = struct.pack(">HHHHHH", txid, 0x0100, 1, 0, 0, 0) return header + qname(HOST) + struct.pack(">HH", 1, 1) @@ -48,8 +47,7 @@ def parse_answers(buf): off += 10 rdata = buf[off:off+rdlen] if rtype == 1 and rdlen == 4: - ip = ".".join(str(b) for b in rdata) - found.append((name, ip, rclass, ttl)) + found.append((name, ".".join(str(b) for b in rdata), rclass, ttl)) off += rdlen return found except Exception as e: @@ -76,6 +74,9 @@ def probe(port, txid, wait=4.0): s.close() if __name__ == "__main__": + if len(sys.argv) != 2: + print(__doc__); sys.exit(1) + HOST = sys.argv[1] + ".local" print("mDNS A?", HOST) print("-- querying :5353") probe(5353, 0x1234)