Files
fgl-aircon/docs/PROTOCOL.md
Petr Polezhaev 157df4e795 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 исключены из версионирования.
2026-09-17 19:44:49 +03:00

504 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 тиков внутреннего счётчика).