# 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`) и сверён с существующим скриптом `docs/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//lan.json` отдаёт `{ "lanip": { "lanip_key": ..., "lanip_key_id": ..., "keepAlive": ..., "autoSync": ... } }`. **[APK]** 2. **mDNS**: приложение опрашивает A-запись `.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":,"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":} ``` `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`. Совпадает с `docs/legacy/aircon/config.py`. **[APK: AylaEncryption.generateSessionKeys]** ### 3.3. Формат защищённого сообщения (envelope) Все сообщения после key exchange в обе стороны — JSON: ``` {"enc":"","sign":""} ``` Открытый текст: `{"seq_no":,"data":}`. * **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= # первый раз (sessionType не активна) PUT http://<модуль>/local_reg.json # далее, пока сессия жива Content-Type: application/json {"local_reg":{"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://:<порт>/local_lan/commands.json ``` Приложение возвращает **ровно одну** команду из очереди (из головы) в envelope: ```json {"seq_no":123,"data":{"properties":[{"property":{"base_type":"integer","name":"fan_speed","value":3,"id":"<8 симв.>","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://:<порт>/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":"","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":"","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` = `-`; байты секрета — в `docs/legacy/aircon/app_mappings.py` (проверено: EU совпадает). Endpoints: ``` POST https:///users/sign_in.json {"user":{"email":"...","password":"...","application":{"app_id":"...","app_secret":"..."}}} → {"access_token":"...", ...} GET https:///apiv1/devices.json (Authorization: auth_token ) GET https:///apiv1/dsns//lan.json → {"lanip":{lanip_key, lanip_key_id, keepAlive,...}} GET https:///apiv1/dsns//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, ключ из `docs/legacy/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 тиков внутреннего счётчика).