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:
2026-09-17 19:44:49 +03:00
commit 024b290b89
25 changed files with 3904 additions and 0 deletions

503
docs/PROTOCOL.md Normal file
View 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` (например
`AC000W00REDACTED.local`), отправляя DNS-query на `224.0.0.251:5353` **и на
`224.0.0.251:10276`** (нестандартный порт Ayla). **[ПРОВЕРЕНО НА ПРИБОРЕ:
модуль отвечает ТОЛЬКО на :10276, на :5353 — нет.** A-ответ, TTL 10,
имя `AC000W00REDACTED.local` → IP модуля. Проба: `tools/probe_mdns.py`.]
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 тиков внутреннего счётчика).