docs: реконструкция LAN-протокола FGLair, анализ legacy, планы fglair-core/HA/ESPHome
- PROTOCOL.md: полная спецификация (KDF, envelope/CBC-цепочка, local_reg, key exchange, commands.json 206/200, datapoint push, тайминги, таблицы свойств шаблонов A/B/F, облачный provisioning). Факты из APK помечены [APK], проверенные живыми экспериментами на AP-WC1E — [ПРОВЕРЕНО НА ПРИБОРЕ]: mDNS только :10276; лимит 2 LAN-сессии (3-я -> 503); принудительный re-key при возрасте сессии >= ~44с (единственный механизм самолечения десинхрона — 400/401 модуль игнорирует); записи не эхируются; delete_session освобождает слот. - LEGACY_ANALYSIS.md: причины рассинхрона (keep-alive 1200с вместо 10-15с + seq_no-фильтр) и перегрузки модуля; требования к новой реализации. - PLAN_CORE_LIBRARY.md: план C++20-библиотеки fglair-core (Linux + ESP-IDF), ключ lanip_key считается статичным, ротация — только ошибка + ручной перепровижининг. - PLAN_HOME_ASSISTANT.md: pyfglair (cffi wheel) + custom component, облачный provisioning только в config flow, ключ виден в диагностике для копирования в ESPHome. - PLAN_ESPHOME.md: external component, только ESP-IDF framework. - tools/probe_reference.py: эталонный клиент протокола (проверен на приборе end-to-end); tools/probe_mdns.py — mDNS-проба. - legacy/: снимок скрипта (апстрим gyro-labs/AirCon, вложенный клон не версионируется); apk/*.apk исключены из версионирования.
This commit is contained in:
150
docs/LEGACY_ANALYSIS.md
Normal file
150
docs/LEGACY_ANALYSIS.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Анализ legacy-скрипта (`legacy/`): соответствие протоколу и найденные проблемы
|
||||
|
||||
Скрипт — форк проекта hisense_ac (deiger), адаптированный под FGLair. Общая логика
|
||||
протокола воспроизведена верно, но есть критические расхождения с APK и поведением
|
||||
модуля (проверено живыми экспериментами на приборе), которые объясняют оба
|
||||
наблюдаемых симптома: «рассинхронизацию ключей» и перегрузку модуля.
|
||||
|
||||
> Живые проверки прибора (AP-WC1E) показали: модуль игнорирует 400/401 на свои
|
||||
> POST, восстанавливается только принудительным re-key по `local_reg` (порог
|
||||
> возраста сессии ≈44 с); максимум 2 LAN-сессии; записи не эхируются.
|
||||
> Подробности — PROTOCOL.md §4.4, §5.3, §6.3, §10.
|
||||
|
||||
## 1. Что воспроизведено корректно
|
||||
|
||||
| Часть | Файл | Оценка |
|
||||
|-------|------|--------|
|
||||
| KDF ключей (app/dev, suffix 0/1/2) | `config.py` | Точно совпадает с `AylaEncryption.generateSessionKeys`; подтверждено на приборе |
|
||||
| AES-256-CBC, zero-pad, HMAC-sign | `query_handlers.py` | Совпадает; CBC-цепочка подтверждена на приборе (несколько последовательных сообщений) |
|
||||
| CBC-цепочка в рамках сессии | `config.py` (один объект cipher) | Совпадает с Java (persist state) |
|
||||
| Роуты `/local_lan/*` | `main.py` | Совпадают с `AylaHttpServer.addMappings` |
|
||||
| Формат commands.json (по одной команде, seq_no, `{}` при пустой очереди) | `query_handlers.py` | Совпадает. **Но**: нет 206/200-различения (см. 2.6) |
|
||||
| Формат datapoint push и GET-ответов | `query_handlers.py` | Совпадает |
|
||||
| local_reg body/методы POST/PUT | `notifier.py` | Совпадает (формат некритичен — проверено) |
|
||||
| Оптимистичное обновление при записи | `aircon.py` (property_updater) | Верно: записи не эхируются (проверено на приборе) |
|
||||
| Облачный discovery (sign_in/devices/lan.json, секреты) | `discovery.py`, `app_mappings.py` | Совпадает (проверено: EU secret = base64url из SECRET_MAP) |
|
||||
| Таблица свойств FGL (шаблон A) | `properties.py` | Частично; много свойств отсутствует (op_status, error_code, powerful_mode, min_heat, coil_dry, device_capabilities, …) — см. PROTOCOL.md §8.2 |
|
||||
|
||||
## 2. Расхождения с APK/прибором (= баги)
|
||||
|
||||
### 2.1. [ГЛАВНАЯ ПРИЧИНА «РАССИНХРОНИЗАЦИИ»] Keep-alive 1200 с вместо 10–15 с
|
||||
|
||||
`notifier.py:_KEEP_ALIVE_INTERVAL = 1200.0`. APK: 10 с (или `lan.json:keepAlive/3`).
|
||||
Проверено на приборе: **единственный механизм восстановления после расхождения
|
||||
CBC-цепочек — принудительный re-key, который модуль делает при получении
|
||||
`local_reg` для сессии старше ≈44 с. Ответы 400/401 модуль игнорирует.**
|
||||
Следствие для legacy: любая потерянная пара запрос-ответ/обрыв соединения →
|
||||
обе стороны «глохнут» на срок до 20 минут (до следующего local_reg). Наблюдаемый
|
||||
симптом «перестаёт понимать кондиционер» с самопроизвольным восстановлением —
|
||||
именно это.
|
||||
|
||||
Дополнительно: длинные паузы между local_reg держат сессию «полуживой»
|
||||
(модуль не видит keep-alive, но слот может удерживаться), и конфликт за
|
||||
2 доступных слота с телефоном/вторым клиентом становится вероятнее.
|
||||
|
||||
### 2.2. [ТЕОРЕТИЧЕСКОЕ] Неверная обработка смены `lanip_key_id`
|
||||
|
||||
`config.py:update` бросает `KeyIdReplaced` → `key_exchange_handler` отвечает
|
||||
**404 Not Found** вместо **412 Precondition Failed** (APK) и никогда не
|
||||
перечитывает `lan.json`. За 5 лет эксплуатации ротация ключа не наблюдалась
|
||||
ни разу (ключ, по-видимому, статичен и зашит в модуль), так что на практике
|
||||
благополучен — но код вводит в заблуждение и чинится тривиально.
|
||||
|
||||
### 2.3. Drop легитимных обновлений по seq_no
|
||||
|
||||
`aircon.py:is_update_valid` отбрасывает обновления с `seq_no` меньше последнего
|
||||
(кроме 0). На приборе: seq_no модуля сбрасывается в 0 **при каждом re-key** и
|
||||
растёт внутри сессии. При штатных (для legacy — раз в 1200 с) re-key'ах фильтр
|
||||
пропускает только первый push сессии (seq 0) и отбрасывает все последующие (1, 2,
|
||||
… < накопленного максимума). APK не проверяет seq_no входящих вообще. Итог:
|
||||
пропущенные обновления состояния после каждого re-key — второй вклад в
|
||||
«скрипт не видит изменений».
|
||||
|
||||
### 2.4. 400 вместо 401 при ошибке расшифровки
|
||||
|
||||
`query_handlers.property_update_handler` возвращает **400**, APK — **401**.
|
||||
На приборе модуль игнорирует оба кода, так что это НЕ причина рассинхрона
|
||||
(первоначальная гипотеза опровергнута экспериментом). Исправить стоит для
|
||||
APK-совместимости, потому что код ответа — часть интерфейса.
|
||||
|
||||
### 2.5. Мелочи шифрования
|
||||
|
||||
* Паддинг: скрипт НЕ добавляет обязательный завершающий NUL (Java добавляет
|
||||
`len+1`). На приборе работает оба варианта; для совместимости повторить Java.
|
||||
* `t_fan_speed`/`t_control_value` (AcDevice/Hisense-свойства) для FGLair-устройств
|
||||
не используются — кодовая basePath висит мёртвым грузом.
|
||||
|
||||
### 2.6. Отсутствие 206-ответов
|
||||
|
||||
`command_handler` всегда отвечает 200. APK отвечает 206, пока очередь не пуста.
|
||||
Без 206 модуль вынужден либо перепрашивать local_reg, либо опрашивать вслепую —
|
||||
вероятный вклад в перегрузку.
|
||||
|
||||
### 2.7. Нет DELETE-команды сессии при завершении
|
||||
|
||||
Скрипт не отправляет `delete_session` — модуль держит полумёртвую сессию в одном
|
||||
из 2 слотов.
|
||||
|
||||
## 3. Причины перегрузки модуля (спам → модуль отключается от Wi-Fi)
|
||||
|
||||
### 3.1. Статусный цикл: 33 GET-команды каждые 600 с
|
||||
|
||||
`main.py:query_status_device` ставит в очередь **по одной GET-команде на каждое
|
||||
свойство** (поля dataclass) каждые 600 с, плюс ещё раз при старте. APK запрашивает
|
||||
все свойства **один раз** при установке сессии (`fetchPropertiesLAN`) и далее
|
||||
живёт на push-обновлениях; поллит отдельные свойства только после команд с
|
||||
побочными эффектами. Постоянный циклический опрос — лишние сотни HTTP-транзакций
|
||||
и AES-операций на приборе, у которого слабый CPU.
|
||||
|
||||
### 3.2. local_reg на каждую команду без debounce
|
||||
|
||||
Каждый `queue_command` → `_queue_listener()` → немедленный `local_reg notify=1`.
|
||||
Действие из HA (mode+temp+fan) = 3 команды = до 3 local_reg подряд. APK шлёт
|
||||
**один** local_reg на пакет команд (AylaLocalNetwork.performRequest).
|
||||
|
||||
### 3.3. Агрессивный цикл Notifier при непустой очереди
|
||||
|
||||
`notifier.py:start`: пока `qsize > 1` — sleep всего 60 с и повторная отправка
|
||||
local_reg. Если модуль «застрял» (не забирает команды), очередь растёт
|
||||
(см. 3.1), local_reg продолжает долбить каждые 60 с + retry-логика tenacity
|
||||
(6 попыток, экспоненциально). Мёртвый цикл под нагрузкой. На приборе подтверждён
|
||||
паттерн: после серий неудачных попыток регистрации модуль может «зависать» в
|
||||
режиме «KE без активации» — долбить его повторными local_reg бесполезно, нужен
|
||||
backoff и пауза (PROTOCOL.md §4.4 п.6).
|
||||
|
||||
### 3.4. Странный старт
|
||||
|
||||
При старте: `query_status_device` немедленно (без начальной задержки) наполняет
|
||||
очередь 33 GET-командами, а `Notifier.start` в первой же итерации отправляет
|
||||
`local_reg` (таймер `last_timestamp=0` срабатывает сразу). Возникает гонка:
|
||||
`notify` в первом POST/PUT зависит от того, успела ли очередь наполниться, и
|
||||
модуль сразу получает «тяжёлый» старт — массовая выдача 33 команд новой сессии.
|
||||
Правильная последовательность (APK): local_reg notify=0 → key exchange → один
|
||||
пакет GET-запросов → далее только push.
|
||||
|
||||
## 4. Прочие замечания
|
||||
|
||||
* MQTT: подписка на `$SYS/broker/log/M/subscribe/#` — hack для перепосылки статуса
|
||||
новым подписчикам; в HA-интеграции не понадобится.
|
||||
* `f_temp_in`/`t_power`-мэппинги — код Hisense-ветки, для FGL не нужен.
|
||||
* Потокобезопасность: pycryptodome cipher используется из одного event-loop — ок,
|
||||
но при любом выносе в треды потребует сериализации (CBC-цепочка!).
|
||||
|
||||
## 5. Требования к новой реализации, вытекающие из анализа
|
||||
|
||||
1. Keep-alive по APK-таймингам: 10–15 с. Это одновременно и период
|
||||
самолечения десинхрона (модуль сам сделает re-key на ≈44-й секунде).
|
||||
2. Воспроизводить Java-поведение в кодах ответов: 401 при ошибках расшифровки,
|
||||
412 при несовпадении key_id, 206/200 в commands.json, NUL-паддинг.
|
||||
3. Начальная синхронизация: один пакет GET всех нужных свойств после key
|
||||
exchange; далее — push-driven. Периодический опрос — только как diagnosка
|
||||
с большим интервалом и по требованию.
|
||||
4. Записи не эхируются: оптимистичное обновление + при необходимости GET-подтверждение.
|
||||
5. Не проверять seq_no входящих сообщений.
|
||||
6. Debounce команд: копить 100–300 мс, отправлять одним пакетом; один local_reg
|
||||
notify=1 на пакет. Не более одного local_reg в ~1 с.
|
||||
7. Rate-limit очереди, backoff при ошибках (включая режим «KE без poll» —
|
||||
пауза, а не долбёжка), корректное завершение (delete_session) для
|
||||
освобождения слота.
|
||||
8. Считать lanip_key статичным: при несовпадении key_id — устойчивая ошибка
|
||||
и перепровижининг вручную (облако не дергать в рантайме).
|
||||
273
docs/PLAN_CORE_LIBRARY.md
Normal file
273
docs/PLAN_CORE_LIBRARY.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# План: кросс-платформенная библиотека `fglair-core` (C++20, Linux + ESP-IDF)
|
||||
|
||||
Целевая аудитория документа — агенты-реализаторы. Протокольные детали — в
|
||||
`PROTOCOL.md` (ссылки вида «§N», факты помечены [ПРОВЕРЕНО НА ПРИБОРЕ]).
|
||||
Причины проектных решений — `LEGACY_ANALYSIS.md`.
|
||||
|
||||
## 1. Цели и не-цели
|
||||
|
||||
Цели:
|
||||
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, чтобы не «завалить» модуль.
|
||||
|
||||
Не-цели (первая версия):
|
||||
* Облако внутри C++-библиотеки. **lanip_key считается статичным, зашитым в
|
||||
модуль** (5 лет эксплуатации без ротаций). Провижининг — отдельный Python
|
||||
CLI (`fglair-discover`), см. §8. При несовпадении `key_id` библиотека
|
||||
переходит в устойчивое состояние ошибки и ждёт смены конфига вручную.
|
||||
* Узловые устройства (`node/*`), setup-режим (RSA `sec`), OTA.
|
||||
* Hisense-свойства (`t_power` и пр.) — только FGLair-шаблоны 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/`)
|
||||
|
||||
```cpp
|
||||
// fgl/types.h
|
||||
enum class fgl_state { idle, registering, online, recovering, offline, key_error };
|
||||
enum class fgl_prop { operation_mode, fan_speed, adjust_temperature,
|
||||
display_temperature, af_vertical_direction, af_vertical_swing,
|
||||
af_horizontal_direction, af_horizontal_swing, economy_mode,
|
||||
powerful_mode, coil_dry_mode, min_heat, outdoor_low_noise,
|
||||
indoor_fan_control, human_det_auto_save, wifi_led_enable,
|
||||
op_status, error_code, device_capabilities, demand_control,
|
||||
get_prop, device_name, building_name, /* ...по PROTOCOL.md §8.2 */ };
|
||||
struct fgl_value { enum kind { boolean, integer, string } type;
|
||||
union { bool b; int32_t i; }; const char* s; };
|
||||
|
||||
struct fgl_config {
|
||||
const char* device_ip; // "192.168.88.3"
|
||||
const char* dsn; // "AC000W002879281"
|
||||
const char* lanip_key; // base64-строка как есть
|
||||
uint32_t lanip_key_id; // 62888
|
||||
fgl_template template_; // A / B / F
|
||||
uint16_t listen_port; // 0 => 10275
|
||||
uint32_t keepalive_ms; // 0 => 15000 (рекомендация; APK: 10с)
|
||||
uint8_t max_queue; // 0 => 16
|
||||
};
|
||||
struct fgl_callbacks {
|
||||
void (*on_state)(void* user, fgl_state st, int err);
|
||||
void (*on_property)(void* user, fgl_prop p, const fgl_value* v);
|
||||
void (*on_log)(void* user, int level, const char* msg, size_t len);
|
||||
void* user;
|
||||
};
|
||||
|
||||
// fgl/session.h (C++-класс; в fgl/c_api.h — extern "C" шейм для cffi)
|
||||
class FglSession {
|
||||
public:
|
||||
static FglSession* create(const fgl_config&, const fgl_callbacks&);
|
||||
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(); // атомарный пакет команд
|
||||
bool cached(fgl_prop, fgl_value* out) const;
|
||||
};
|
||||
```
|
||||
|
||||
Таблица свойств — `const` массивы в rodata по шаблонам (имя, base_type,
|
||||
read-only, диапазоны), см. PROTOCOL.md §8.
|
||||
|
||||
## 4. Поведенческие требования (обязательны к точной реализации)
|
||||
|
||||
Ссылки на PROTOCOL.md; всё, что ниже, согласовано с живыми тестами прибора.
|
||||
|
||||
### 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'ами.
|
||||
|
||||
### 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) — для телеметрии/диагностики.
|
||||
|
||||
### 4.3. Очередь команд и pacing
|
||||
* Ограничение очереди `max_queue` (16). Coalescing: новый write того же
|
||||
свойства замещает предыдущий незабранный; GET-дубликаты отбрасываются.
|
||||
* `commands.json`: отдать **одну** команду из головы; 206, если очередь
|
||||
непуста, иначе 200. Шифрование строго последовательно (CBC-цепочка),
|
||||
паддинг — Java-вариант (≥1 NUL).
|
||||
* Записи не эхируются (проверено): после выдачи write обновить кэш
|
||||
оптимистично; опционально (по конфигу) подтвердить GET-ом через 1–2 с.
|
||||
|
||||
### 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), не ошибка.
|
||||
|
||||
### 4.5. Завершение
|
||||
* `stop()`: поставить команду DELETE `local_reg.json`/`delete_session`,
|
||||
дождаться выдачи (≤2 с), закрыть сервер. Освобождает слот немедленно
|
||||
(проверено) — важно из-за лимита в 2 сессии.
|
||||
|
||||
### 4.6. HTTP-сервер
|
||||
* Минимальный HTTP/1.1: GET/POST, `Content-Length`, keep-alive, query-параметры.
|
||||
Один поток, последовательная обработка, RST-обрывы от модуля — норма.
|
||||
* Роутинг на сессию по IP клиента (как `deviceWithLanIP` в APK). Мульти-сессии
|
||||
(несколько устройств) — через `FglHub` (M6).
|
||||
|
||||
## 5. Криптография
|
||||
|
||||
* 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. Таблица свойств
|
||||
|
||||
`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. Структура репозитория
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Зависимости: mbedtls (IDF встроен; Linux — системный или FetchContent 3.x),
|
||||
Python 3 (тесты/инструменты). Оценка ресурсов (ESP32, IDF): RAM < 20 КБ на
|
||||
сессию, код ядра ~35–50 КБ + mbedtls.
|
||||
|
||||
## 8. Провижининг (вне библиотеки)
|
||||
|
||||
* `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).
|
||||
|
||||
## 9. Тестирование
|
||||
|
||||
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.
|
||||
|
||||
## 10. Этапы (milestones)
|
||||
|
||||
| # | Содержимое | Критерии приёмки |
|
||||
|---|------------|------------------|
|
||||
| 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 |
|
||||
|
||||
## 11. Риски и открытые вопросы
|
||||
|
||||
* Режим «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-сокетах, конфликтов нет.
|
||||
153
docs/PLAN_ESPHOME.md
Normal file
153
docs/PLAN_ESPHOME.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# План: внешний компонент ESPHome (`fglair`)
|
||||
|
||||
Аудитория — агенты-реализаторы. Протокол — `docs/PROTOCOL.md`; ядро —
|
||||
`docs/PLAN_CORE_LIBRARY.md` (`fglair-core`, C++20). Компонент строится по
|
||||
образцу штатных climate-модулей ESPHome (midea, hisense-ac, tuya), но протокол
|
||||
вынесен в переиспользуемое C++-ядро.
|
||||
|
||||
Требования к среде: **только ESP-IDF framework** (Arduino-фреймворк ESPHome
|
||||
считается устаревшим и не поддерживается). Ядро — C++20 без исключений/RTTI,
|
||||
что совместимо с дефолтными флагами сборки ESPHome для IDF.
|
||||
|
||||
## 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/
|
||||
```
|
||||
|
||||
Ядро компилируется как статическая библиотека через CMakeLists компонента;
|
||||
платформенный слой — `platform/esp-idf` (lwip-сокеты, esp_timer, esp_random).
|
||||
|
||||
## 2. YAML-конфигурация
|
||||
|
||||
```yaml
|
||||
external_components:
|
||||
- source: github://<user>/esphome-fglair@main
|
||||
components: [fglair]
|
||||
|
||||
fglair:
|
||||
devices:
|
||||
- id: ac_living
|
||||
ip_address: 192.168.88.3 # либо dsn + mdns: true (запрос на :10276)
|
||||
dsn: AC000W002879281
|
||||
lanip_key: 1uWOP3nGbSz2ysEfjJJVu+P0fxAQTg==
|
||||
lanip_key_id: 62888
|
||||
template: A # A|B|F
|
||||
# port: 10275 # локальный порт сервера (default)
|
||||
# keepalive: 15s
|
||||
|
||||
climate:
|
||||
- platform: fglair
|
||||
device_id: ac_living
|
||||
name: "Кондиционер"
|
||||
# swing: both # off|vertical|horizontal|both
|
||||
|
||||
sensor:
|
||||
- platform: fglair
|
||||
device_id: ac_living
|
||||
room_temperature: {name: "Температура в комнате"}
|
||||
error_code: {name: "Код ошибки"}
|
||||
|
||||
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"}
|
||||
```
|
||||
|
||||
**Откуда брать ключ**: у пользователя обычно уже есть HA-интеграция (или
|
||||
запускается `fglair-discover` CLI из репозитория ядра). HA-диагностика
|
||||
устройства показывает `lanip_key`/`lanip_key_id` — значения копируются в YAML
|
||||
вручную. Ключ статичен (зашит в модуль), автоматическая синхронизация не
|
||||
предусмотрена.
|
||||
|
||||
**Поведение при смене ключа**: ядро отвечает 412 и переходит в `key_error`;
|
||||
компонент логирует ошибку с текстом «lanip_key устарел, обновите конфиг» и
|
||||
останавливает сессию (не долбит модуль). Обновление — правка YAML вручную.
|
||||
|
||||
## 3. Компонент `fglair` (hub, `__init__.py`)
|
||||
|
||||
* `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) возвращает.
|
||||
|
||||
## 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.
|
||||
|
||||
## 5. Остальные платформы
|
||||
|
||||
* `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.
|
||||
|
||||
## 6. Ограничения и требования
|
||||
|
||||
* Платы: 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 мин); ядро переживает это штатно (проверено на приборе).
|
||||
|
||||
## 7. Тесты и приёмка
|
||||
|
||||
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 с.
|
||||
|
||||
## 8. Этапы
|
||||
|
||||
| # | Содержимое |
|
||||
|---|-----------|
|
||||
| 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, релиз |
|
||||
146
docs/PLAN_HOME_ASSISTANT.md
Normal file
146
docs/PLAN_HOME_ASSISTANT.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# План: интеграция для Home Assistant (`fglair` custom component)
|
||||
|
||||
Аудитория — агенты-реализаторы. Протокол — `docs/PROTOCOL.md`, библиотека —
|
||||
`docs/PLAN_CORE_LIBRARY.md` (`fglair-core`, C++20).
|
||||
|
||||
## 1. Архитектура
|
||||
|
||||
```
|
||||
Home Assistant (custom component `fglair`)
|
||||
│ использует python-пакет pyfglair (cffi-bindings к libfglair-core.so)
|
||||
▼
|
||||
pyfglair (wheel: linux x86_64/aarch64; cffi; собирает fglair-core через cmake)
|
||||
│
|
||||
▼
|
||||
fglair-core (C++) ←—— mock-тесты; тот же код, что и на 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-веткой).
|
||||
|
||||
## 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 (перенос `legacy/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`.
|
||||
|
||||
## 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 с).
|
||||
|
||||
Вариант B — ручной: ввод ip/dsn/lanip_key/lanip_key_id по полям или импорт
|
||||
существующего `config_*.json` (миграция с legacy-скрипта).
|
||||
|
||||
Ключ считается **статичным** (см. PLAN_CORE_LIBRARY §8): облако используется
|
||||
только здесь, при настройке. В рантайме — никогда.
|
||||
|
||||
Repair-флоу (редкий, теоретический):
|
||||
* Состояние ядра `key_error` (несовпадение `key_id`) → repair «Ключ устройства
|
||||
изменён — перепровижинируйте». Повторный вход в облако по требованию
|
||||
пользователя, обновление полей ConfigEntry. Никаких автоматических
|
||||
перечитываний облака.
|
||||
|
||||
**Диагностика**: Device-страница HA показывает `lanip_key`, `lanip_key_id`,
|
||||
`dsn`, ip — чтобы пользователь мог скопировать их в конфиг ESPHome (см.
|
||||
PLAN_ESPHOME). В redacted-дамп lanip_key маскируется, в полном — виден.
|
||||
|
||||
## 4. Структура компонента
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## 5. Маппинг сущностей (шаблон A; B/F — по capabilities)
|
||||
|
||||
**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` скрывают недоступные режимы.
|
||||
|
||||
**Sensors**: room temp, error_code (с текстами), op_status (флаги defrost /
|
||||
oil recovery / pump down / maintenance / check operation / запреты).
|
||||
**Binary sensor**: connectivity (ONLINE). **Switch/Select** — по списку §4.
|
||||
|
||||
Записи идут batch'ем на действие пользователя (одно `batch_commit()` на вызов
|
||||
`set_temperature`/`set_hvac_mode`/…) — ядро само склеит в один local_reg notify.
|
||||
|
||||
## 6. Runtime-модель
|
||||
|
||||
* Один `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, освобождает слот на модуле).
|
||||
|
||||
## 7. Требования к надёжности (acceptance)
|
||||
|
||||
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, повторная регистрация.
|
||||
|
||||
## 8. Тесты
|
||||
|
||||
* `pytest` + mock `pyfglair` (fake session, scripted callbacks): flow, entities,
|
||||
repair, unload.
|
||||
* Интеграционный тест с mock-модулем (`test/integration/mock_ac.py` ядра) через
|
||||
настоящий wheel: полный цикл в CI.
|
||||
* Ручной чек-лист на реальном приборе (совпадает с M5 ядра).
|
||||
|
||||
## 9. Этапы
|
||||
|
||||
| # | Содержимое |
|
||||
|---|-----------|
|
||||
| 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) |
|
||||
503
docs/PROTOCOL.md
Normal file
503
docs/PROTOCOL.md
Normal file
@@ -0,0 +1,503 @@
|
||||
# FGLair / Ayla LAN-протокол — спецификация
|
||||
|
||||
Документ реконструирован по декомпилированному APK FGLair 3.4.3 (`com.fujitsu.fglair`,
|
||||
SDK `com.aylanetworks.aylasdk`, см. `AylaLanModule.java`, `AylaEncryption.java`,
|
||||
`AylaLanMessage.java`, `CreateDatapointCommand.java`, `AylaHttpServer.java`,
|
||||
`com.cafbit.netlib.dns.NetThread`) и сверён с существующим скриптом `legacy/`.
|
||||
Всё, что помечено **[APK]**, подтверждено кодом приложения; **[LEGACY]** — известно
|
||||
только из скрипта; **[ПРОВЕРЕНО НА ПРИБОРЕ]** — проверено живыми экспериментами
|
||||
на AP-WC1E (сентябрь 2026, см. §10); **[HYP]** — правдоподобная гипотеза,
|
||||
требует проверки на приборе.
|
||||
|
||||
## 1. Обзор
|
||||
|
||||
Кондиционер (модуль Wi-Fi, далее «модуль») работает с облаком Ayla
|
||||
(`ads-eu.aylanetworks.com` для EU). Приложение FGLair дополнительно умеет работать
|
||||
с модулем напрямую в локальной сети («LAN mode»), не выходя в облако.
|
||||
|
||||
Протокол — HTTP/1.1 JSON поверх TCP, где **модуль сам инициирует почти всё общение**:
|
||||
|
||||
```
|
||||
(1) local_reg (POST/PUT) (2) key_exchange (POST)
|
||||
Приложение ------------------------------> Модуль (порт 80)
|
||||
(HTTP-сервер <------------------------------- ...
|
||||
:10275) 202 Accepted (3) commands.json (GET) ------>
|
||||
<------------------------------- (4) datapoint.json (POST) ---->
|
||||
(5) datapoint/ack.json (POST)->
|
||||
```
|
||||
|
||||
Роли:
|
||||
* **Приложение** (наш будущий код) — HTTP-сервер на порту **10275** (fallback:
|
||||
любой свободный, номер сообщается модулю в `local_reg`) и HTTP-клиент для
|
||||
`local_reg`.
|
||||
* **Модуль** — HTTP-сервер на порту **80** и HTTP-клиент для запросов (3)–(5)
|
||||
к приложению.
|
||||
|
||||
Модуль поддерживает **до 2 одновременных LAN-сессий** [ПРОВЕРЕНО НА ПРИБОРЕ] —
|
||||
например, телефон с приложением + сервер умного дома; третья регистрация
|
||||
отклоняется (HTTP 503).
|
||||
|
||||
## 2. Обнаружение устройства
|
||||
|
||||
1. **Облако**: `GET https://ads-eu.aylanetworks.com/apiv1/devices.json` содержит
|
||||
`lan_ip` каждого устройства. Плюс `GET /apiv1/dsns/<DSN>/lan.json` отдаёт
|
||||
`{ "lanip": { "lanip_key": ..., "lanip_key_id": ..., "keepAlive": ..., "autoSync": ... } }`. **[APK]**
|
||||
2. **mDNS**: приложение опрашивает A-запись `<DSN>.local` (например
|
||||
`AC000W002879281.local`), отправляя DNS-query на `224.0.0.251:5353` **и на
|
||||
`224.0.0.251:10276`** (нестандартный порт Ayla). **[ПРОВЕРЕНО НА ПРИБОРЕ:
|
||||
модуль отвечает ТОЛЬКО на :10276, на :5353 — нет.** A-ответ, TTL 10,
|
||||
имя `AC000W002879281.local` → IP модуля. Проба: `tools/probe_mdns.py`.]
|
||||
3. **Кэш** приложения хранит последние lan_ip/lanip_key. **[APK]**
|
||||
|
||||
Для библиотеки минимумом является статическая конфигурация вида `config_kata.json`
|
||||
(ip, lanip_key, lanip_key_id, dsn); mDNS — опциональное улучшение
|
||||
(запрос только на порт 10276).
|
||||
|
||||
## 3. Шифрование
|
||||
|
||||
### 3.1. Обмен ключами
|
||||
|
||||
Модуль отправляет на сервер приложения:
|
||||
|
||||
```
|
||||
POST /local_lan/key_exchange.json
|
||||
{"key_exchange":{"ver":1,"proto":1,"key_id":62888,"random_1":"<16 алфанум. симв.>","time_1":<int>,"sec":""}}
|
||||
```
|
||||
|
||||
* `ver` и `proto` обязаны быть `1` (AES-256-CBC + HMAC-SHA256). Иначе — **426 Upgrade Required**. **[APK]**
|
||||
* `sec` непустой только для RSA-режима первичной настройки (setup); в LAN-режее
|
||||
должен отсутствовать/быть пустым. **[APK]**
|
||||
* `key_id` — номер `lanip_key`, полученного из облака (`lan.json`). Если не совпал
|
||||
с локальным — **412 Precondition Failed** + приложение обязано перечитать
|
||||
`lan.json` из облака (`refreshLanConfig`) и разрешить LAN заново. **[APK]**
|
||||
|
||||
Приложение отвечает (HTTP 200):
|
||||
|
||||
```
|
||||
{"random_2":"<16 алфанум. симв.>","time_2":<int>}
|
||||
```
|
||||
|
||||
`time_2` в Java — `System.nanoTime()`; значения time НЕ синхронизируются и не
|
||||
проверяются — это просто материал для KDF. **[APK]**
|
||||
|
||||
### 3.2. Вывод сессионных ключей (KDF)
|
||||
|
||||
Обозначим: `K = lanip_key.encode('utf-8')` (строка base64 как есть, НЕ декодированная),
|
||||
`R1, R2, T1, T2` — utf-8 байты `random_1, random_2, str(time_1), str(time_2)`.
|
||||
|
||||
```
|
||||
msg_app = R1 | R2 | T1 | T2 | X # X — один байт: 0x30, 0x31 или 0x32
|
||||
msg_dev = R2 | R1 | T2 | T1 | X # те же варианты X
|
||||
|
||||
key = HMAC_SHA256(K, HMAC_SHA256(K, msg) || msg) # 32 байта
|
||||
```
|
||||
|
||||
* X=0x30 → `sign_key` (ключ HMAC для подписи сообщений)
|
||||
* X=0x31 → `crypto_key` (ключ AES-256)
|
||||
* X=0x32 → `iv_seed` = первые **16 байт** результата (начальный IV)
|
||||
|
||||
Направления:
|
||||
* **app-ключи** (приложение шифрует/подписывает, модуль проверяет) — из `msg_app`;
|
||||
* **dev-ключи** (модуль шифрует, приложение проверяет) — из `msg_dev`.
|
||||
|
||||
Совпадает с `legacy/config.py`. **[APK: AylaEncryption.generateSessionKeys]**
|
||||
|
||||
### 3.3. Формат защищённого сообщения (envelope)
|
||||
|
||||
Все сообщения после key exchange в обе стороны — JSON:
|
||||
|
||||
```
|
||||
{"enc":"<base64 AES-256-CBC>","sign":"<base64 HMAC-SHA256>"}
|
||||
```
|
||||
|
||||
Открытый текст: `{"seq_no":<int>,"data":<JSON-объект или {}>}`.
|
||||
|
||||
* **AES-256-CBC без стандартизированного паддинга**. Паддинг нулями до кратности
|
||||
16 байт, причём Java-реализация добавляет **как минимум один нулевой байт**
|
||||
(C-string терминатор: `len+1`, затем до кратности 16). При чтении — обрезаются
|
||||
все завершающие нулевые байты. Реализация должна корректно принимать оба
|
||||
варианта паддинга. **[APK: encryptEncapsulateSign / unencodeDecrypt]**
|
||||
* `sign` — HMAC-SHA256 с `sign_key` соответствующего направления поверх **байт
|
||||
открытого текста без паддинга** (включая `seq_no` и `data`).
|
||||
* `seq_no` приложения — статический счётчик, инкрементируется на каждое исходящее
|
||||
сообщение (никогда не сбрасывается, в т.ч. между сессиями). **[APK]**
|
||||
`seq_no` модуля — свой счётчик; периодически сбрасывается в 0 **[LEGACY]**.
|
||||
|
||||
### 3.4. КРИТИЧНО: цепочность CBC
|
||||
|
||||
AES-CBC объект создаётся один раз на сессию, и **каждое следующее сообщение
|
||||
продолжает цепочку CBC с того места, где закончилось предыдущее** (в Java —
|
||||
повторные вызовы `Cipher.update`; в pycryptodome — повторные `encrypt`/`decrypt`
|
||||
одного объекта). Начальный IV — только `iv_seed`.
|
||||
|
||||
Следствия:
|
||||
* Потеря или повреждение любого сообщения в канале (таймаут, обрыв соединения,
|
||||
перезагрузка одной из сторон, отклонённое сообщение) **безвозвратно разводит
|
||||
цепочки сторон** — все последующие сообщения не расшифровываются.
|
||||
* Единственный механизм восстановления — новый key exchange (см. §6.3).
|
||||
* Реализация обязана строго сериализовать все шифрования/расшифровки сессии.
|
||||
|
||||
## 4. Канал управления (приложение → модуль)
|
||||
|
||||
### 4.1. Регистрация / keep-alive: `local_reg.json`
|
||||
|
||||
```
|
||||
POST http://<модуль>/local_reg.json?dsn=<DSN> # первый раз (sessionType не активна)
|
||||
PUT http://<модуль>/local_reg.json # далее, пока сессия жива
|
||||
Content-Type: application/json
|
||||
|
||||
{"local_reg":{"ip":"<ip приложения>","port":10275,"uri":"/local_lan","notify":0|1}}
|
||||
```
|
||||
|
||||
* `notify=1` — «у меня есть команды, забери». `notify=0` — просто keep-alive. **[APK]**
|
||||
* Успешный ответ модуля — `202 Accepted` **[ПРОВЕРЕНО НА ПРИБОРЕ]**.
|
||||
* **Слоты сессий: максимум 2 одновременные LAN-сессии** (на приборе: 3-я
|
||||
регистрация получает `HTTP 503`) **[ПРОВЕРЕНО НА ПРИБОРЕ]**. Т.е. телефон +
|
||||
сервер умного дома уживаются; третий клиент — нет.
|
||||
* Формат тела/заголовков некритичен (проверены компактный/spaced JSON, с
|
||||
полным набором заголовков и без) **[ПРОВЕРЕНО НА ПРИБОРЕ]**.
|
||||
* Параметр `?dsn=` добавляется только пока сессия ещё не активна. **[APK]**
|
||||
* Для setup-устройств добавляется поле `key` (RSA public key) — вне scope. **[APK]**
|
||||
|
||||
### 4.2. Выборка команд: `commands.json`
|
||||
|
||||
После `local_reg` (особенно с `notify=1`) модуль опрашивает:
|
||||
|
||||
```
|
||||
GET http://<ip приложения>:<порт>/local_lan/commands.json
|
||||
```
|
||||
|
||||
Приложение возвращает **ровно одну** команду из очереди (из головы) в envelope:
|
||||
|
||||
```json
|
||||
{"seq_no":123,"data":{"properties":[{"property":{"base_type":"integer","name":"fan_speed","value":3,"id":"<8 симв.>","dsn":"<DSN>","metadata":...}}]}}
|
||||
```
|
||||
|
||||
или запрос свойства:
|
||||
|
||||
```json
|
||||
{"seq_no":124,"data":{"cmds":[{"cmd":{"cmd_id":5,"method":"GET","resource":"property.json?name=fan_speed","data":"","uri":"/local_lan/property/datapoint.json"}}]}}
|
||||
```
|
||||
|
||||
или `{}` («пусто»): `{"seq_no":125,"data":{}}`.
|
||||
|
||||
* HTTP-статус: **206 Partial Content**, если в очереди остались ещё команды; иначе **200 OK**. Модуль сам продолжает опрос при 206. **[APK: getResponseCode]**
|
||||
* `cmd_id` — инкрементальный id GET-команд; ответ модуля на GET придёт в
|
||||
`datapoint.json` с query-параметром `?cmd_id=5` (см. §5.1). **[APK]**
|
||||
* `id` внутри property-команды — случайные 8 символов; нужен только если свойства
|
||||
включён `ack_enabled` (для FGLair-свойств ack не используется); по нему
|
||||
сопоставляется ack. **[APK: CreateDatapointCommand]**
|
||||
* Удаление сессии — тоже команда: `{"cmds":[{"cmd":{"cmd_id":0,"method":"DELETE","resource":"local_reg.json","data":"delete_session","uri":"/local_lan"}}]}`. **[APK: DeleteSessionCommand]**
|
||||
|
||||
### 4.3. Тайминги (по APK; уточнено на приборе)
|
||||
|
||||
* Keep-alive: приложение отправляет `local_reg` каждые **10 с** по умолчанию; если
|
||||
`lan.json` вернул `keepAlive` (секунды), интервал = `keepAlive / 3`. **[APK]**
|
||||
* При постановке команд в очередь приложение шлёт `local_reg` с `notify=1`
|
||||
**немедленно** — но один на пакет команд, не на каждую команду. **[APK:
|
||||
AylaLocalNetwork.performRequest — registerCommands() + sendLocalRegistration()]**
|
||||
* Каждый обработанный `commands.json` **перезапускает таймер keep-alive**
|
||||
(`startKeepalive()` после выдачи команды) — во время активного опроса
|
||||
дополнительный keep-alive не отправляется. **[APK]**
|
||||
* Ожидание ответа GET-команды: `max(50 s, n * 1.5 s)` на пакет из n команд, без
|
||||
ретраев. Ack-таймаут datapoint — 10 с (по умолчанию). **[APK]**
|
||||
* Чтение свойств: в официальном приложении стартовые значения приходят из облака
|
||||
или кэша; по LAN полный слепок можно получить пакетом из n GET-команд
|
||||
(`fetchPropertiesLAN` — все имена одним пакетом, ответ придёт push'ами).
|
||||
Далее приложение полагается на push-обновления (§5). Опрос конкретного
|
||||
свойства — по необходимости. **[APK + JS]**
|
||||
|
||||
### 4.4. Политика re-key и жизненный цикл сессии [ПРОВЕРЕНО НА ПРИБОРЕ]
|
||||
|
||||
Ключевое эмпирическое поведение модуля (AP-WC1E, fw 2.6.17-fgl2):
|
||||
|
||||
1. `local_reg` от endpoint'а **без** живой сессии → модуль отправляет key
|
||||
exchange (если есть свободный слот), затем **сразу** (≈0.2–0.5 с) делает
|
||||
один «пустой» опрос `commands.json` — это признак принятой сессии.
|
||||
2. `local_reg` от endpoint'а с живой сессией **моложе ~40 с** → только
|
||||
keep-alive, без key exchange.
|
||||
3. `local_reg` от endpoint'а с сессией **старше ~44 с** → модуль принудительно
|
||||
инициирует новый key exchange (ротация сессионных ключей). Т.е. при штатном
|
||||
keep-alive каждые 10–15 с ключи ротируются примерно каждые 45–60 с.
|
||||
`time_1` модуля — тикающий счётчик с шагом ≈10 нс (аптайм); порог,
|
||||
вероятно, 44 с в этих единицах либо просто 4.4e9 тиков.
|
||||
4. **Ответы 401/400 на POST модуля игнорируются**: сессия продолжает работать,
|
||||
re-key не вызывается. Единственный механизм восстановления после расхождения
|
||||
CBC-цепочек — принудительный re-key по `local_reg` (п. 3). Поэтому интервал
|
||||
keep-alive = интервал потенциального «зависания» при десинхроне.
|
||||
5. `delete_session` освобождает слот немедленно; следующий `local_reg` того же
|
||||
endpoint'а создаёт новую сессию.
|
||||
6. Наблюдавшийся (не воспроизведённый повторно) режим отказа: модуль отвечает
|
||||
key exchange'ом, но не делает «пустой» опрос и не забирает команды; сессия
|
||||
не активируется. Возникал после серий неудачных key exchange (возможно,
|
||||
«застрявшие» слоты); проходил сам через ~10–20 минут покоя. При реализации:
|
||||
детектировать отсутствие poll'а в течение N секунд после KE и уходить в
|
||||
backoff, а не долбить повторными local_reg.
|
||||
7. `seq_no` модуля инкрементируется на каждый push в рамках сессии
|
||||
(0, 1, 2, …) и сбрасывается в 0 при каждом re-key. Проверять его на
|
||||
монотонность **нельзя** (см. также §9.5).
|
||||
|
||||
## 5. Канал телеметрии (модуль → приложение)
|
||||
|
||||
### 5.1. Обновление свойства
|
||||
|
||||
```
|
||||
POST http://<ip приложения>:<порт>/local_lan/property/datapoint.json?cmd_id=N&status=200
|
||||
Content-Type: application/json
|
||||
{"enc":"...","sign":"..."}
|
||||
```
|
||||
|
||||
* Query-параметры **[ПРОВЕРЕНО НА ПРИБОРЕ]**: ответ на GET-команду приходит с
|
||||
`?cmd_id=N&status=200` (статус применения команды; `cmd_id` соответствует
|
||||
id запроса). Спонтанные обновления — без параметров.
|
||||
* Открытый текст `data`:
|
||||
|
||||
```json
|
||||
{"name":"operation_mode","value":3,"metadata":{...},"dsn":"<DSN узла>","dev_time_ms":0}
|
||||
```
|
||||
|
||||
* `metadata`, `dsn` (для узловых устройств), `dev_time_ms` — опциональны. **[APK]**
|
||||
* Ответ приложения: 200/206 с пустым телом. Java при ошибке расшифровки отвечает
|
||||
**401 Unauthorized**; **на приборе доказано, что модуль игнорирует и 401, и 400**
|
||||
(сессия продолжает работать) — это НЕ механизм восстановления, см. §4.4.
|
||||
* Варианты путей: `/local_lan/node/property/datapoint.json` — то же для узлов
|
||||
(гейтвей), вне scope. **[APK]**
|
||||
|
||||
### 5.2. Ack на datapoint
|
||||
|
||||
```
|
||||
POST .../local_lan/property/datapoint/ack.json
|
||||
data: {"id":"<id команды>","ack_status":200,"ack_message":0,"dsn":"..."}
|
||||
```
|
||||
|
||||
`ack_status != 200` — ошибка применения. Только для свойств с `ack_enabled`.
|
||||
Для проверенных свойств FGLair (wifi_led_enable, get_prop) ack не приходит.
|
||||
**[частично ПРОВЕРЕНО НА ПРИБОРЕ]**
|
||||
|
||||
### 5.3. Эхо на записи НЕТ [ПРОВЕРЕНО НА ПРИБОРЕ]
|
||||
|
||||
Запись свойства (`properties`-команда, §4.2) забирается модулем и применяется,
|
||||
но **не эхируется** в LAN: ни datapoint-push с новым значением, ни ack.
|
||||
(Именно поэтому legacy-скрипт обновляет состояние оптимистично в момент выдачи
|
||||
команды.) Если нужно подтверждение — запросить свойство GET-командой через
|
||||
короткую задержку. Спонтанные push'и приходят только на изменения, инициированные
|
||||
самим прибором/пультом (и, вероятно, для свойств с побочными эффектами).
|
||||
|
||||
### 5.3. Прочие callback-пути (для полноты)
|
||||
|
||||
* `/local_lan/node/conn_status.json` — статус узлов гейтвея.
|
||||
* `/local_lan/status.json`, `/local_lan/connect_status`, `/local_lan/wifi_scan*.json`,
|
||||
`/local_lan/regtoken.json`, `/local_lan/wifi_stop_ap.json` — режим setup (не нужны
|
||||
в рабочей сессии).
|
||||
|
||||
## 6. Сессия
|
||||
|
||||
### 6.1. Установка [ПРОВЕРЕНО НА ПРИБОРЕ]
|
||||
|
||||
```
|
||||
приложение: POST local_reg.json (notify=0|1) # регистрирует свой ip:port (202)
|
||||
модуль: POST /local_lan/key_exchange.json # генерирует сессионные ключи
|
||||
приложение: 200 {"random_2","time_2"}
|
||||
модуль: GET /local_lan/commands.json # «пустой» опрос сразу (≈0.5с)
|
||||
приложение: (пакет GET-команд) + local_reg notify=1 # начальная синхронизация
|
||||
модуль: GET commands.json (цикл по 206) + POST datapoint.json × n
|
||||
...далее: push-обновления свойств + опрос commands.json после notify=1
|
||||
```
|
||||
|
||||
### 6.2. Поддержание
|
||||
|
||||
Приложение шлёт `local_reg` каждые 10–15 с (APK: 10 с по умолчанию /
|
||||
`keepAlive/3` из lan.json). **Сессия живёт, пока приходят local_reg**; при
|
||||
возрасте сессии ≥ ~44 с очередной local_reg вызывает принудительный re-key
|
||||
(ротацию ключей) — это штатный режим (§4.4). При пропадании модуля (сеть/питание)
|
||||
— повторные попытки с backoff, mDNS-переобнаружение.
|
||||
|
||||
### 6.3. Разрыв и восстановление [ПРОВЕРЕНО НА ПРИБОРЕ]
|
||||
|
||||
* **Потеря CBC-цепочки** (§3.4): модуль не может расшифровать ответ приложения /
|
||||
приложение не может расшифровать push модуля. Ответы 401/400 на POST модуля
|
||||
**игнорируются** — модуль продолжает слать в «сломанный» канал. Восстановление
|
||||
происходит только когда очередной `local_reg` (по возрасту ≥ ~44 с или от
|
||||
нового endpoint'а) вызовет новый key exchange. Следствие: **интервал
|
||||
keep-alive = максимальное время «мёртвой» сессии при десинхроне**
|
||||
(10–15 с — незаметно; 1200 с как в legacy-скрипте — 20 минут глухоты).
|
||||
* **Смена lanip_key** (`key_id` не совпал): теоретический путь по APK — 412 +
|
||||
`refreshLanConfig()` из облака. За 5 лет эксплуатации прибора ротации ключа
|
||||
не наблюдалось ни разу; ключ, по-видимому, зашит в модуль, облако лишь хранит
|
||||
его копию. Реализация: 412 + переход в устойчивое состояние ошибки до
|
||||
перепровижининга вручную (см. планы).
|
||||
* Явное завершение: команда DELETE `local_reg.json`/`delete_session` (§4.2) —
|
||||
освобождает слот немедленно. **Рекомендуется слать при штатном выключении**,
|
||||
чтобы не занимать один из 2 слотов модуля.
|
||||
|
||||
## 7. Облачная часть (для provisioning/discovery)
|
||||
|
||||
Серверы (Field) **[APK: ServiceUrls.java]**:
|
||||
|
||||
| Регион | User-сервис | Device-сервис |
|
||||
|--------|-------------|---------------|
|
||||
| EU | `user-field-eu.aylanetworks.com` | `ads-eu.aylanetworks.com` |
|
||||
| US | `user-field.aylanetworks.com` | `ads-field.aylanetworks.com` |
|
||||
| CN | `user-field.ayla.com.cn` | `ads-field.ayla.com.cn` |
|
||||
|
||||
Аутентификация приложения **[APK: AylaNetworkWrapper.java]**:
|
||||
|
||||
* EU: `app_id=FGLair-eu-id`, `app_secret=FGLair-eu-gpFbVBRoiJ8E3QWJ-QRULLL3j3U`
|
||||
* US: `app_id=CJIOSP-id`, `app_secret=CJIOSP-Vb8MQL_lFiYQ7DKjN0eCFXznKZE`
|
||||
* CN: `app_id=FGLairField-cn-id`, `app_secret=FGLairField-cn-zezg7Y60YpAvy3HPwxvWLnd4Oh4`
|
||||
|
||||
`app_secret` = `<prefix>-<base64url-nopad(секрет)>`; байты секрета — в
|
||||
`legacy/app_mappings.py` (проверено: EU совпадает).
|
||||
|
||||
Endpoints:
|
||||
|
||||
```
|
||||
POST https://<user>/users/sign_in.json
|
||||
{"user":{"email":"...","password":"...","application":{"app_id":"...","app_secret":"..."}}}
|
||||
→ {"access_token":"...", ...}
|
||||
|
||||
GET https://<ads>/apiv1/devices.json (Authorization: auth_token <t>)
|
||||
GET https://<ads>/apiv1/dsns/<dsn>/lan.json → {"lanip":{lanip_key, lanip_key_id, keepAlive,...}}
|
||||
GET https://<ads>/apiv1/dsns/<dsn>/properties.json[?names[]=..&..] → описание свойств
|
||||
```
|
||||
|
||||
`properties.json` для FGLair-устройств возвращает объекты Ayla-свойств:
|
||||
`name, base_type, read_only, ack_enabled, direction, display_name, ...`
|
||||
(см. `AylaProperty.java`). Для LAN-only работы библиотека может хранить таблицу
|
||||
свойств статически (§8).
|
||||
|
||||
## 8. Модель свойств FGLair
|
||||
|
||||
### 8.1. Типы устройств (oem_model → шаблон) [APK: FGLDeviceTemplateType.java + JS template_types]
|
||||
|
||||
| Шаблон | Модели |
|
||||
|--------|--------|
|
||||
| A | AP-WA1E…WA6E, AP-WC1E…WC4E, AP-WD1E |
|
||||
| B | AP-WB1E…WB4E |
|
||||
| F | AP-WF1E…WF4E |
|
||||
|
||||
`config_kata.json` → `AP-WC1E` → **шаблон A**.
|
||||
|
||||
### 8.2. Список свойств по шаблонам
|
||||
|
||||
**A**: operation_mode, fan_speed, adjust_temperature, af_vertical_direction,
|
||||
af_vertical_swing, af_horizontal_direction, af_horizontal_swing,
|
||||
outdoor_low_noise, indoor_fan_control, human_det_auto_save, min_heat,
|
||||
powerful_mode, coil_dry_mode, economy_mode, master_timer_on_off_1,
|
||||
master_timer_on_off_2, error_code, demand_control, filter_sign_reset_display,
|
||||
op_status, device_name, building_name, wifi_led_enable, service_contact_name,
|
||||
service_contact_phone, service_contact_email, af_horizontal_num_dir,
|
||||
af_vertical_num_dir, device_capabilities, display_temperature, get_prop,
|
||||
human_det, refresh.
|
||||
|
||||
**B**: operation_mode, fan_speed, adjust_temperature, af_vertical_move_step1,
|
||||
af_horizontal_move_step1, economy_mode, master_timer_on_off_1/2, error_code,
|
||||
demand_control, filter_sign_reset_display, op_status, device_name,
|
||||
building_name, wifi_led_enable, service_contact_*, device_capabilities, refresh.
|
||||
|
||||
**F**: как A + monitor1, filter_sign_reset (вместо filter_sign_reset_display).
|
||||
|
||||
### 8.3. Семантика значений (шаблон A; подтверждено JS-бандлом приложения)
|
||||
|
||||
| Свойство | Тип | Значения |
|
||||
|----------|-----|----------|
|
||||
| `operation_mode` | int | 0=OFF, 1=ON, 2=AUTO, 3=COOL, 4=DRY, 5=FAN, 6=HEAT. Вкл/выкл питания — запись 0/1. |
|
||||
| `fan_speed` | int | 0=Quiet, 1=Low, 2=Medium, 3=High, 4=Auto |
|
||||
| `adjust_temperature` | int | Уставка, единица 0.1 °C (250 = 25.0). Диапазон таблицы приложения: −10.0…45.0 (−100…450); фактически прибор ограничен 16…30 [LEGACY]. Шаг UI: 0.5 °C (шаблон B — 1.0 °C). |
|
||||
| `display_temperature` | int, ro | Температура в помещении, единица 0.01 °C, смещение 5000 (5000 = 50.00 °C), шаг 25. Приближение: `T = (v − 5000)/100`. Приложение использует таблицу соответствия display↔adjust (221 строка, −10…45 °C). |
|
||||
| `af_vertical_direction`, `af_horizontal_direction` | int | Положение заслонки 0…N−1, где N = `af_vertical_num_dir` / `af_horizontal_num_dir` (если N>15 → поддерживается только 0). |
|
||||
| `af_vertical_swing`, `af_horizontal_swing` | int | 0=выкл, 1=вкл |
|
||||
| `economy_mode`, `powerful_mode`, `coil_dry_mode`, `min_heat`, `outdoor_low_noise`, `human_det_auto_save`, `wifi_led_enable`, `indoor_fan_control` | bool(int) | 0/1 |
|
||||
| `op_status` | int, ro | Битовая маска, см. §8.4 |
|
||||
| `device_capabilities` | int, ro | Битовая маска, см. §8.5 |
|
||||
| `error_code` | int, ro | Код ошибки (0 — нет; таблица приложения до 4095) |
|
||||
| `demand_control` | int | Деманд-контроль (ограничение мощности) |
|
||||
| `get_prop` | int | Триггер: запись 1 → прибор обновит `display_temperature` (свойство вернётся в 0) |
|
||||
| `refresh` | int | Триггер полной синхронизации (приложение пишет через облако, value "1") |
|
||||
| `master_timer_on_off_1/2` | int | Таймеры вкл/выкл (2 шт.) |
|
||||
| `filter_sign_reset_display` | int | Сброс индикации замены фильтра |
|
||||
|
||||
### 8.4. `op_status` — биты
|
||||
|
||||
| Бит | Значение |
|
||||
|-----|----------|
|
||||
| 0–18 | Запреты (центральное управление): 0 все операции, 1 таймер, 2 уставка температуры, 3 режим, 4 старт/стоп, 5 старт, 6 сброс фильтра, 7 работа, 8 температура, 9 auto, 10 cool, 11 dry, 12 heat, 13 fan, 14 level1-работа, 15 level1-старт/стоп, 16 level2-работа, 17 level2-таймер, 18 level2-локальные настройки |
|
||||
| 21 | (только B) разморозка/масло/разные режимы |
|
||||
| 22 | обслуживание (maintenance) |
|
||||
| 24 | разморозка (defrost) |
|
||||
| 25 | «разные режимы» (одновременные операции) |
|
||||
| 28 | oil recovery |
|
||||
| 29 | pump down |
|
||||
| 30 | check operation |
|
||||
|
||||
### 8.5. `device_capabilities` — биты
|
||||
|
||||
| Бит | Возможность |
|
||||
|-----|-------------|
|
||||
| 0 | cool |
|
||||
| 1 | dry |
|
||||
| 2 | fan |
|
||||
| 3 | heat |
|
||||
| 4 | auto |
|
||||
| 5 | fan auto |
|
||||
| 6 | fan high |
|
||||
| 7 | fan medium |
|
||||
| 8 | fan low |
|
||||
| 9 | fan quiet |
|
||||
| 10 | вертикальный swing |
|
||||
| 11 | горизонтальный swing |
|
||||
| 12 | economy |
|
||||
| 13 | minimum heat |
|
||||
| 14 | energy swing fan (indoor_fan_control) |
|
||||
| 16 | powerful |
|
||||
| 17 | outdoor low noise |
|
||||
| 18 | coil dry |
|
||||
|
||||
### 8.6. Особые последовательности приложения (для справки)
|
||||
|
||||
* Включение питания = запись `operation_mode = 1`, выключение = `operation_mode = 0`
|
||||
(сохранённый режим восстанавливается прибором сам).
|
||||
* min_heat ON → прибор сам меняет режим на heat и уставку 24 °C; приложение
|
||||
дополнительно поллит `operation_mode`/`min_heat`/`adjust_temperature` до стабилизации.
|
||||
* Изменение `af_*_direction` при включённом swing → ждёт обновления swing.
|
||||
* После команд с побочными эффектами приложение поллит 1 свойство с интервалом
|
||||
1 с, таймаут 30–600 с (облако); в LAN-режиме поллинг не нужен — приходит push.
|
||||
|
||||
## 9. Ограничения и наблюдения для реализации
|
||||
|
||||
1. **Модуль чувствителен к частоте запросов**: официальное приложение отправляет
|
||||
`local_reg` ≤ 1/10 с и всегда «пакетом», никогда — по одному на команду. Спам
|
||||
`local_reg`/большими пачками команд перегружает модуль до отвала Wi-Fi
|
||||
(подтверждено опытом legacy-скрипта, см. `LEGACY_ANALYSIS.md`).
|
||||
2. Ответ модуля на `local_reg`: 202 (успех), **503 — нет свободных слотов**
|
||||
(2 сессии), при недоступности — таймаут/отказ соединения.
|
||||
3. `commands.json` возвращает одну команду за запрос; батч реализуется цепочкой
|
||||
206-ответов. Не следует отдавать несколько команд в одном ответе — формат
|
||||
это формально позволяет (`cmds`/`properties` — массивы), но приложение так не
|
||||
делает; модуль, вероятно, применяет только первую [HYP].
|
||||
4. Zero-padding без NUL работает (на приборе), но для совместимости лучше
|
||||
повторять Java-вариант (всегда ≥ 1 нулевой байт).
|
||||
5. seq_no модуля сбрасывается при каждом re-key и растёт внутри сессии — не
|
||||
отбрасывайте «устаревшие» обновления из-за seq_no (в Java seq_no входящих
|
||||
вообще не проверяется; «прилипший» фильтр по seq_no в legacy-скрипте —
|
||||
источник потерянных обновлений, см. LEGACY_ANALYSIS §2.4).
|
||||
6. Записи не эхируются — обновляйте локальное состояние оптимистично и/или
|
||||
подтверждайте GET-ом (§5.3).
|
||||
7. Максимальное окно «глухоты» при десинхроне = интервал keep-alive (§6.3).
|
||||
|
||||
## 10. Проверено на приборе / осталось неизвестным
|
||||
|
||||
Проверено на AP-WC1E (fw 2.6.17-fgl2, ключ из `config_kata.json`), см. также §2,
|
||||
§4.1, §4.4, §5.1, §5.3, §6: полный цикл сессии, KDF/CBC-цепочка/подписи в обе
|
||||
стороны, GET/запись свойств, re-key, 401/400-игнорирование, 2 слота + 503,
|
||||
delete_session, mDNS :10276. Рабочий эталонный клиент: `tools/probe_reference.py`.
|
||||
|
||||
Осталось неизвестным / требует проверки:
|
||||
* Точная семантика `status=` в query ответов на GET-команды (видели только 200).
|
||||
* Таймаут фактического освобождения слота при пропадении приложения без
|
||||
delete_session (ориентировочно ≤ 60–120 с; re-key-порог 44 с измерен точно).
|
||||
* Реакция модуля на несколько команд в одном `commands.json`-ответе.
|
||||
* Причина редкого режима «KE без активации сессии» (§4.4 п.6) — воспроизводится
|
||||
только после серий неудачных попыток.
|
||||
* Ровно ли 44 с порог re-key (измерено в границах 39–44 с; принято «≈44 с»,
|
||||
возможно 4.4e9 тиков внутреннего счётчика).
|
||||
66
docs/README.md
Normal file
66
docs/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# aircon / FGLair local control — документация
|
||||
|
||||
Реконструкция LAN-протокола FGLair (Fujitsu General, платформа Ayla) и планы
|
||||
реализации стека локального управления кондиционером.
|
||||
|
||||
## Состав
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|-----------|
|
||||
| `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_reference.py` | Эталонный клиент протокола (проверен на приборе). |
|
||||
| `../tools/probe_mdns.py` | mDNS-проба (`<DSN>.local`, порт 10276). |
|
||||
|
||||
## Краткая выжимка протокола
|
||||
|
||||
* Модуль кондиционера (порт 80) сам подключается к серверу приложения (порт 10275):
|
||||
`local_reg.json` (keep-alive/notify) → `key_exchange.json` → poll
|
||||
`commands.json` + push `property/datapoint.json`.
|
||||
* Шифрование: AES-256-CBC (no-padding, zero-pad) + HMAC-SHA256; ключи выводятся
|
||||
из облачного `lanip_key` и двух пар (random, time). **CBC-цепочка непрерывна
|
||||
в рамках сессии**.
|
||||
* **Ключевая механика надёжности** (проверено на приборе): модуль игнорирует
|
||||
400/401-ответы; единственное самолечение — принудительный re-key, который
|
||||
модуль делает при получении `local_reg` для сессии старше ≈44 с. Поэтому
|
||||
keep-alive должен быть 10–15 с — тогда любая рассинхронизация живет секунды,
|
||||
а не 20 минут (как в legacy-скрипте с интервалом 1200 с).
|
||||
* Максимум 2 LAN-сессии (телефон + сервер уживаются), третья — HTTP 503.
|
||||
* Записи свойств не эхируются — состояние обновляется оптимистично,
|
||||
подтверждение через GET.
|
||||
* Свойства FGLair (шаблоны A/B/F по oem_model): `operation_mode` (0..6),
|
||||
`fan_speed` (0..4), `adjust_temperature` (×0.1 °C), `display_temperature`
|
||||
((v−5000)/100 °C), swing/заслонки, флаги economy/powerful/…, битмаски
|
||||
`op_status`, `device_capabilities`. Полные таблицы — в PROTOCOL.md §8.
|
||||
|
||||
## Ключевые решения (по уточнениям владельца)
|
||||
|
||||
* `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.
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
1. `fglair-core` (M0–M5) — ядро, mock-тесты, эталон уже проверен на приборе.
|
||||
2. `pyfglair` + HA-интеграция (H1–H4) — параллельно с E1–E2.
|
||||
3. ESPHome-компонент (E1–E4).
|
||||
4. Уточнение оставшихся неизвестных (PROTOCOL.md §10) по мере эксплуатации.
|
||||
|
||||
## Источники
|
||||
|
||||
* APK FGLair 3.4.3 (`apk/com.fujitsu.fglair.apk`): классы
|
||||
`com.aylanetworks.aylasdk.lan.*`, `com.fujitsugeneral.aylasdk.*`,
|
||||
`com.cafbit.netlib.dns.NetThread`, JS-бандл `assets/www/dist/build.js`.
|
||||
* Legacy-скрипт (`legacy/`) — форк hisense_ac; вложенный клон апстрима:
|
||||
https://github.com/gyro-labs/AirCon (лежит в `legacy/aircon/`, не версионируется).
|
||||
* Живые эксперименты на AP-WC1E (сентябрь 2026): сессии, re-key, 401/400,
|
||||
слоты/503, delete_session, записи, mDNS. Пробы: `tools/probe_*.py`
|
||||
(история — сессия анализа; рабочие артефакты оставлены в tools/).
|
||||
Reference in New Issue
Block a user