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

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Первоисточник для анализа — бинарники APK не версионируем (файлы лежат локально)
apk/*.apk
# Python
__pycache__/
*.pyc
.venv/
venv/
# Вложенный клон апстрима legacy-скрипта (https://github.com/gyro-labs/AirCon)
legacy/aircon/

BIN
apk/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

1
apk/manifest.json Normal file
View File

@@ -0,0 +1 @@
{"xapk_version":2,"package_name":"com.fujitsu.fglair","name":"FGLair","version_code":"30162","version_name":"3.4.3","min_sdk_version":"24","target_sdk_version":"36","permissions":["android.permission.INTERNET","android.permission.WRITE_EXTERNAL_STORAGE","android.permission.RECORD_AUDIO","android.permission.RECORD_VIDEO","android.permission.READ_EXTERNAL_STORAGE","android.permission.ACCESS_COARSE_LOCATION","android.permission.ACCESS_FINE_LOCATION","android.permission.ACCESS_NETWORK_STATE","android.permission.VIBRATE","android.permission.CHANGE_NETWORK_STATE","android.permission.ACCESS_WIFI_STATE","android.permission.CHANGE_WIFI_MULTICAST_STATE","android.permission.CHANGE_WIFI_STATE","android.permission.BLUETOOTH","android.permission.BLUETOOTH_ADMIN","com.fujitsu.fglair.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"],"split_configs":["config.en","config.fr","config.mdpi"],"total_size":20439665,"icon":"icon.png","split_apks":[{"file":"com.fujitsu.fglair.apk","id":"base"},{"file":"config.en.apk","id":"config.en"},{"file":"config.fr.apk","id":"config.fr"},{"file":"config.mdpi.apk","id":"config.mdpi"}]}

150
docs/LEGACY_ANALYSIS.md Normal file
View 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
View 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.0.2.3"
const char* dsn; // "AC000W00REDACTED"
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
View 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.0.2.3 # либо dsn + mdns: true (запрос на :10276)
dsn: AC000W00REDACTED
lanip_key: REDACTED-LANIP-KEY
lanip_key_id: 62888
template: A # A|B|F
# port: 10275 # локальный порт сервера (default)
# keepalive: 15s
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
View 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
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 тиков внутреннего счётчика).

66
docs/README.md Normal file
View 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/).

0
legacy/__init__.py Normal file
View File

583
legacy/aircon.py Normal file
View File

@@ -0,0 +1,583 @@
from copy import deepcopy
from dataclasses import dataclass, field, fields
import enum
import logging
import random
import re
import string
import threading
import time
from typing import Any, Callable, Dict, List
import queue
from Crypto.Cipher import AES
from . import control_value
from .config import Config, Encryption
from .error import Error
from .properties import (AcProperties, AirFlow, AirFlowState, Economy, FanSpeed, FastColdHeat,
FglProperties, FglBProperties, HumidifierProperties, Properties, Power,
AcWorkMode, Quiet, TemperatureUnit, SleepMode)
@dataclass(order=True)
class Command:
priority: int
timestamp: int # Aligns equal priority commands in FIFO.
command: Dict = field(compare=False)
updater: Callable = field(compare=False)
class Device(object):
_FGL_DEVICES = re.compile(r'AP-W[ACDF]\dE')
_FGLB_DEVICES = re.compile(r'AP-WB\dE')
_HUMI_DEVICES = re.compile(r'0001-0401-000[12]')
def __init__(self, config: Dict[str, str], properties: Properties, notifier: Callable[[None],
None]):
self.name = config['name']
self.app = config['app']
self.model = config['model']
self.sw_version = config['sw_version']
self.mac_address = config['mac_address']
self.ip_address = config['ip_address']
self.temp_type = (TemperatureUnit.CELSIUS
if config.get('temp_type') == 'C' else TemperatureUnit.FAHRENHEIT)
self._config = Config(config['lanip_key'], config['lanip_key_id'])
self._properties = properties
self._properties_lock = threading.RLock()
self._queue_listener = notifier
self._available = None
self.topics = {}
self.work_modes = []
self.fan_modes = []
self._next_command_id = 0
self.commands_queue = queue.PriorityQueue()
self._commands_seq_no = 0
self._commands_seq_no_lock = threading.Lock()
self._updates_seq_no = 0
self._updates_seq_no_lock = threading.Lock()
self._property_change_listeners = [] # type List[Callable[[str, Any], None]]
@classmethod
def create(cls, config: Dict[str, str], notifier: Callable[[None], None]):
model = config['model']
if cls._FGL_DEVICES.fullmatch(model):
return FglDevice(config, notifier)
if cls._FGLB_DEVICES.fullmatch(model):
return FglBDevice(config, notifier)
if cls._HUMI_DEVICES.fullmatch(model):
return HumidifierDevice(config, notifier)
return AcDevice(config, notifier)
@property
def is_fahrenheit(self) -> bool:
return self.temp_type == TemperatureUnit.FAHRENHEIT
@property
def available(self) -> bool:
# Return False if was not set yet.
return self._available or False
@available.setter
def available(self, value: bool):
if self._available != value:
self._available = value
self._notify_listeners('available', 'online' if value else 'offline', retain=True)
def add_property_change_listener(self, listener: Callable[[str, Any], None]):
self._property_change_listeners.append(listener)
def remove_property_change_listener(self, listener: Callable[[str, Any], None]):
self._property_change_listeners.remove(listener)
def _notify_listeners(self, prop_name: str, value, retain: bool = False):
for listener in self._property_change_listeners:
listener(self.mac_address, prop_name, value, retain)
def get_all_properties(self) -> Properties:
with self._properties_lock:
return deepcopy(self._properties)
def get_property(self, name: str):
"""Get a stored property (or None if doesn't exist)."""
with self._properties_lock:
return getattr(self._properties, name, None)
def get_property_type(self, name: str):
return self._properties.get_type(name)
def parse_property(self, name: str, value):
return self._properties.parse_attr(name, value)
def update_property(self, name: str, value, notify_value=None) -> None:
"""Update the stored properties, if changed."""
# Update value precision for value sent from the A/C
if name == "adjust_temperature":
value = round(value * 0.1)
else:
precision = self._properties.get_update_precision(name)
if precision != 1:
value = round(value * precision)
if notify_value is None:
notify_value = value
with self._properties_lock:
old_value = getattr(self._properties, name)
logging.debug(f"Updating {self}.{name} to {value}")
if value != old_value:
setattr(self._properties, name, value)
# logging.debug('Updated properties: %s' % self._properties)
if name == 't_control_value':
self._update_controlled_properties(value)
logging.debug(f"Updated {self}.{name} to {getattr(self._properties, name)}")
self._notify_listeners(name, notify_value)
def _update_controlled_properties(self, control: int):
raise NotImplementedError()
def get_command_seq_no(self) -> int:
with self._commands_seq_no_lock:
seq_no = self._commands_seq_no
self._commands_seq_no += 1
return seq_no
def is_update_valid(self, cur_update_no: int) -> bool:
with self._updates_seq_no_lock:
# Every once in a while the sequence number is zeroed out, so accept it.
if self._updates_seq_no > cur_update_no and cur_update_no > 0:
logging.error('Stale update found %d. Last update used is %d.', cur_update_no,
self._updates_seq_no)
return False # Old update
self._updates_seq_no = cur_update_no
return True
def queue_command(self, name: str, value) -> None:
if self._properties.get_read_only(name):
raise Error('Cannot update read-only property "{}".'.format(name))
data_type = self._properties.get_type(name)
# Device mode is set using t_control_value
if issubclass(data_type, enum.Enum):
data_value = data_type[value]
elif data_type is int and type(value) is str and '.' in value:
# Round rather than fail if the input is a float.
# This is commonly the case for temperatures converted by HA from Celsius.
data_value = round(float(value))
else:
data_value = data_type(value)
# If device has set t_control_value it is being controlled by this field.
if name != 't_control_value' and self.get_property('t_control_value') and name != 't_sleep':
self._convert_to_control_value(name, data_value)
return
# Update value precision for value to be sent to the A/C
precision = self._properties.get_precision(name)
if name == 'adjust_temperature':
data_value = data_value * 10
elif precision != 1:
data_value = round(data_value / precision)
typed_value = data_value
if issubclass(data_type, enum.Enum):
data_value = data_value.value
typed_value = data_type[value]
command = self._build_command(name, data_value)
# There are (usually) no acks on commands, so also queue an update to the
# property, to be run once the command is sent.
property_updater = lambda: self.update_property(name, typed_value)
# Add as a high priority command.
self.commands_queue.put_nowait(Command(10, time.time_ns(), command, property_updater))
self._queue_listener()
def _build_command(self, name: str, data_value: int):
base_type = self._properties.get_base_type(name)
return {
'properties': [{
'property': {
'base_type': base_type,
'name': name,
'value': data_value,
'id': ''.join(random.choices(string.ascii_letters + string.digits, k=8)),
}
}]
}
def _convert_to_control_value(self, name: str, value) -> int:
raise NotImplementedError()
def queue_status(self) -> None:
for data_field in fields(self._properties):
command = {
'cmds': [{
'cmd': {
'method': 'GET',
'resource': 'property.json?name=' + data_field.name,
'uri': '/local_lan/property/datapoint.json',
'data': '',
'cmd_id': self._next_command_id,
}
}]
}
self._next_command_id += 1
# Add as a lower-priority command.
self.commands_queue.put_nowait(Command(100, time.time_ns(), command, None))
self._queue_listener()
def update_key(self, key: dict) -> dict:
return self._config.update(key)
def get_app_encryption(self) -> Encryption:
return self._config.app
def get_dev_encryption(self) -> Encryption:
return self._config.dev
class AcDevice(Device):
def __init__(self, config: Dict[str, str], notifier: Callable[[None], None]):
super().__init__(config, AcProperties(), notifier)
self.topics = {
'env_temp': 'f_temp_in',
'fan_speed': 't_fan_speed',
'work_mode': 't_work_mode',
'power': 't_power',
'swing_mode': 't_fan_power',
'temp': 't_temp'
}
self.work_modes = ['off', 'fan_only', 'heat', 'cool', 'dry', 'auto']
self.fan_modes = ['auto', 'lower', 'low', 'medium', 'high', 'higher']
# @override to add special support for t_power.
def update_property(self, name: str, value) -> None:
with self._properties_lock:
# HomeAssistant expects an 'off' work mode when the AC is off.
notify_value = 'off' if name == 't_work_mode' and self.get_power() == Power.OFF else None
super().update_property(name, value, notify_value)
# HomeAssistant doesn't listen to changes in t_power, so notify also on a t_work_mode change.
if name == 't_power':
work_mode = 'off' if value == Power.OFF else self.get_work_mode()
self._notify_listeners('t_work_mode', work_mode)
# @override to add special support for t_power.
def queue_command(self, name: str, value) -> None:
# HomeAssistant doesn't have a designated turn on button in climate.mqtt.
# Furthermore, turn_on doesn't send the right command...
if name == 't_work_mode':
if value == 'OFF':
# Pass the command to t_power instead of t_work_mode.
name = 't_power'
else:
# Also turn on the AC (if it hasn't already).
super().queue_command('t_power', 'ON')
# Run base.
super().queue_command(name, value)
# Handle turning on FastColdHeat
if name == 't_temp_heatcold' and value == 'ON':
super().queue_command('t_fan_speed', 'AUTO')
super().queue_command('t_fan_mute', 'OFF')
super().queue_command('t_sleep', 'STOP')
super().queue_command('t_temp_eight', 'OFF')
def get_env_temp(self) -> int:
return self.get_property('f_temp_in')
def set_power(self, setting: Power) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_power(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_power', setting)
def get_power(self) -> Power:
control = self.get_property('t_control_value')
if (control):
return control_value.get_power(control)
else:
return self.get_property('t_power')
def set_temperature(self, setting: int) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_temp(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_temp', setting)
def get_temperature(self) -> int:
control = self.get_property('t_control_value')
if (control):
return control_value.get_temp(control)
else:
return self.get_property('t_temp')
def set_sleep(self, setting: SleepMode) -> None:
self.queue_command('t_control_value', setting)
def get_sleep(self) -> SleepMode:
self.get_property('t_sleep')
def set_work_mode(self, setting: AcWorkMode) -> None:
control = self.get_property('t_control_value')
if (control):
if control_value.get_power(control) == Power.OFF:
control = control_value.set_power(control, Power.ON)
control = control_value.set_work_mode(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_work_mode', setting)
def get_work_mode(self) -> AcWorkMode:
control = self.get_property('t_control_value')
if (control):
return control_value.get_work_mode(control)
else:
return self.get_property('t_work_mode')
def set_fan_speed(self, setting: FanSpeed) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_fan_speed(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_fan_speed', setting)
def get_fan_speed(self) -> FanSpeed:
control = self.get_property('t_control_value')
if (control):
return control_value.get_fan_speed(control)
else:
return self.get_property('t_fan_speed')
def set_fan_vertical(self, setting: AirFlow) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_fan_power(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_fan_power', setting)
def get_fan_vertical(self) -> AirFlow:
control = self.get_property('t_control_value')
if (control):
return control_value.get_fan_power(control)
else:
return self.get_property('t_fan_power')
def set_fan_horizontal(self, setting: AirFlow) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_fan_lr(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_fan_leftright', setting)
def get_fan_horizontal(self) -> AirFlow:
control = self.get_property('t_control_value')
if (control):
return control_value.get_fan_lr(control)
else:
return self.get_property('t_fan_leftright')
def set_fan_mute(self, setting: Quiet) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_fan_mute(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_fan_mute', setting)
def get_fan_mute(self) -> Quiet:
control = self.get_property('t_control_value')
if (control):
return control_value.get_fan_mute(control)
else:
return self.get_property('t_fan_mute')
def set_fast_heat_cold(self, setting: FastColdHeat):
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_heat_cold(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_temp_heatcold', setting)
def get_fast_heat_cold(self) -> FastColdHeat:
control = self.get_property('t_control_value')
if (control):
return control_value.get_heat_cold(control)
else:
return self.get_property('t_temp_heatcold')
def set_eco(self, setting: Economy) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_eco(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_eco', setting)
def get_eco(self) -> Economy:
control = self.get_property('t_control_value')
if (control):
return control_value.get_eco(control)
else:
return self.get_property('t_eco')
def set_temptype(self, setting: TemperatureUnit) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_temptype(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_temptype', setting)
def get_temptype(self) -> TemperatureUnit:
control = self.get_property('t_control_value')
if (control):
return control_value.get_temptype(control)
else:
return self.get_property('t_temptype')
def set_swing(self, setting: AirFlowState) -> None:
control = self.get_property("t_control_value")
control = control_value.clear_up_change_flags(control)
if control:
if setting == AirFlowState.OFF:
control = control_value.set_fan_power(control, AirFlow.OFF)
control = control_value.set_fan_lr(control, AirFlow.OFF)
elif setting == AirFlowState.VERTICAL_ONLY:
control = control_value.set_fan_power(control, AirFlow.ON)
control = control_value.set_fan_lr(control, AirFlow.OFF)
elif setting == AirFlowState.HORIZONTAL_ONLY:
control = control_value.set_fan_power(control, AirFlow.OFF)
control = control_value.set_fan_lr(control, AirFlow.ON)
elif setting == AirFlowState.VERTICAL_AND_HORIZONTAL:
control = control_value.set_fan_power(control, AirFlow.ON)
control = control_value.set_fan_lr(control, AirFlow.ON)
self.queue_command("t_control_value", control)
else:
if setting == AirFlowState.OFF:
self.queue_command("t_fan_speed", AirFlow.OFF)
self.queue_command("t_fan_leftright", AirFlow.OFF)
elif setting == AirFlowState.VERTICAL_ONLY:
self.queue_command("t_fan_speed", AirFlow.ON)
self.queue_command("t_fan_leftright", AirFlow.OFF)
elif setting == AirFlowState.HORIZONTAL_ONLY:
self.queue_command("t_fan_speed", AirFlow.OFF)
self.queue_command("t_fan_leftright", AirFlow.ON)
elif setting == AirFlowState.VERTICAL_AND_HORIZONTAL:
self.queue_command("t_fan_speed", AirFlow.ON)
self.queue_command("t_fan_leftright", AirFlow.ON)
def _convert_to_control_value(self, name: str, value) -> int:
if name == 't_power':
return self.set_power(value)
elif name == 't_fan_speed':
return self.set_fan_speed(value)
elif name == 't_work_mode':
return self.set_work_mode(value)
elif name == 't_temp_heatcold':
return self.set_fast_heat_cold(value)
elif name == 't_eco':
return self.set_eco(value)
elif name == 't_temp':
return self.set_temperature(value)
elif name == 't_fan_power':
return self.set_fan_vertical(value)
elif name == 't_fan_leftright':
return self.set_fan_horizontal(value)
elif name == 't_fan_mute':
return self.set_fan_mute(value)
elif name == 't_temptype':
return self.set_temptype(value)
else:
logging.error('Cannot convert to control value property {}'.format(name))
raise ValueError()
def _update_controlled_properties(self, control: int):
power = control_value.get_power(control)
self.update_property('t_power', power)
fan_speed = control_value.get_fan_speed(control)
self.update_property('t_fan_speed', fan_speed)
work_mode = control_value.get_work_mode(control)
self.update_property('t_work_mode', work_mode)
temp_heatcold = control_value.get_heat_cold(control)
self.update_property('t_temp_heatcold', temp_heatcold)
eco = control_value.get_eco(control)
self.update_property('t_eco', eco)
temp = control_value.get_temp(control)
self.update_property('t_temp', temp)
fan_power = control_value.get_fan_power(control)
self.update_property('t_fan_power', fan_power)
fan_horizontal = control_value.get_fan_lr(control)
self.update_property('t_fan_leftright', fan_horizontal)
fan_mute = control_value.get_fan_mute(control)
self.update_property('t_fan_mute', fan_mute)
temptype = control_value.get_temptype(control)
self.update_property('t_temptype', temptype)
class FglDevice(Device):
def __init__(self, config: Dict[str, str], notifier: Callable[[None], None]):
super().__init__(config, FglProperties(), notifier)
self.topics = {
'fan_speed': 'fan_speed',
'work_mode': 'operation_mode',
'temp': 'adjust_temperature',
'display_temperature': 'display_temperature',
}
self.work_modes = ['off', 'fan_only', 'heat', 'cool', 'dry', 'auto']
self.fan_modes = ['auto', 'quiet', 'low', 'medium', 'high']
class FglBDevice(Device):
def __init__(self, config: Dict[str, str], notifier: Callable[[None], None]):
super().__init__(config, FglBProperties(), notifier)
self.topics = {
'fan_speed': 'fan_speed',
'work_mode': 'operation_mode',
'temp': 'adjust_temperature',
'display_temperature': 'display_temperature',
}
self.work_modes = ['off', 'fan_only', 'heat', 'cool', 'dry', 'auto']
self.fan_modes = ['auto', 'quiet', 'low', 'medium', 'high']
class HumidifierDevice(Device):
def __init__(self, config: Dict[str, str], notifier: Callable[[None], None]):
super().__init__(config, HumidifierProperties(), notifier)
self.topics = {'env_temp': 'temp', 'power': 'switch'}

74
legacy/app_mappings.py Normal file
View File

@@ -0,0 +1,74 @@
AYLA_USER_SERVERS = {
'us': 'user-field.aylanetworks.com',
'eu': 'user-field-eu.aylanetworks.com',
'cn': 'user-field.ayla.com.cn',
}
AYLA_DEVICES_SERVERS = {
'us': 'ads-field.aylanetworks.com',
'eu': 'ads-eu.aylanetworks.com',
'cn': 'ads-field.ayla.com.cn',
}
SECRET_MAP = {
'oem-us':
b'\x1dgAPT\xd1\xa9\xec\xe2\xa2\x01\x19\xc0\x03X\x13j\xfc\xb5\x91',
'mid-us':
b'\xdeCx\xbe\x0cq8\x0b\x99\xb4Z\x93>\xfc\xcc\x9ag\x98\xf8\x14',
'tornado-us':
b'\x87O\xf2.&;X\xfb\xf6L\xfdRq\'\x0f\t6\x0c\xfd)',
'wwh-us':
b'(\xcb9w\xc5\xc9\xb7\xab{*k8T!Yb\xaa\xcf\xd0\x85',
'winia-us':
b'\xeb_\xce\xb2\xc6\xff`\xa9\xfa\xa8r\x1c\x0bH\xf8\xe27\xa7U\xec',
'york-us':
b'\xc6A\x7fHyV<\xb2\xa2\xde<\x1f{c\xa9\rt\x9fy\xef',
'beko-eu':
b'\xa9C\n\xdb\xf7+\x01\xe2X\ne\x85\x06\x89\xaa\x88ZP+\x07>~s{\xd3\x1f\x05\x91&\x8c\x81\x84&\xe11\xef=s"*\xa4',
'oem-eu':
b'a\x1ez\xf5\xc4\x0f\x18~\xe5\xeb\xb1\x9f\xe4\xf5&B\xfe#\x88\xcb>\x06O,y\xc1\x06c\x9d\x99J\xc2x\xac\xeb\x82\x93\xe5\r\x89d',
'mid-eu':
b'\x05$\xe6\xecW\xa3\xd1B\xa0\x84\xab*\xf0\x04\x80\xce\xae\xe5`\xc4>w\xf8\xc4\xf3X\xf6<\xd2\xd2I\x14!\xd0\x98\xed\xf2\xab\xae\xc6\x03',
'haxxair':
b'\xd8\xaf\x89--\x00\xabI\x93\x83j\xab\x9acX\xac^\x90f;',
'fglair-cn':
b'\xcd\xec\xe0\xed\x8e\xb4b\x90/\xcbq\xcf\xc3\x1b\xd6.wx:\x1e',
'fglair-eu':
b'\x82\x91[T\x14h\x88\x9f\x04\xdd\x05\x89\xf9\x04T,\xb2\xf7\x8fu',
'fglair-us':
b'U\xbf\x0c@\xbf\xe5\x16&\x10\xec2\xa37G\x82\x15|\xe7)\x91',
'field-us':
b'\xc8b\x08\xfa\xce8\xf8\xf1\x81\xa5\x81\x8fX\xb4\x80\xc0\xdc\xf5\ny',
'huihe-us':
b'\xa2\xbcZ3\xbch\xfa7.`\xbc\xef0\xa3p\xa1\xf0\xaf\xf4\xd4',
'denali-us':
b'\xf1\'\xb0K \xdbZ\xd84;\xeb\x02\xa2\xee\x008\xda\x95\xfd\x93',
'hisense-eu':
b'\xc0\xedK,\xff+X\xfa\xf6p\x87\xaa\xbcV\x88\xfbI\xb4\xcf\xad',
'hisense-us':
b'x\x04\xdf\xef6\x08\x8e\x06\n\x97\xfc\xed4m\xd8\xc7\xa3=\xce\x9f',
'hismart-eu':
b'0\x07\xe9\x04a\xa6e\xc4\x1c\x08+"\r\x84w\x91\x8f\xa8)\x98',
'hismart-us':
b'\xd6+\x1f\xb0b\t\x19G\x87\x8c\xaak\xd0\xf8y\xf5\x933\xafp',
}
SECRET_ID_MAP = {
'haxxair': 'HAXXAIR',
'field-us': 'pactera-field-f624d97f-us',
'fglair-cn': 'FGLairField-cn',
'fglair-eu': 'FGLair-eu',
'fglair-us': 'CJIOSP',
'huihe-us': 'huihe-d70b5148-field-us',
'denali-us': 'DenaliAire',
'hisense-eu': 'Hisense',
'hisense-us': 'APP1',
'hismart-eu': 'Hismart',
'hismart-us': 'App1',
}
SECRET_ID_EXTRA_MAP = {
'denali-us': 'iA',
'hisense-eu': 'mw',
'hisense-us': 'pg',
'hismart-eu': 'fA',
'hismart-us': 'Lg',
}
# Most ACs are using Fahrenheit in their API. These do not:
CELSIUS_BASED_APPS = {'fglair-eu', 'hisense-eu', 'hismart-eu'}

73
legacy/config.py Normal file
View File

@@ -0,0 +1,73 @@
from Crypto.Cipher import AES
from dataclasses import dataclass
import hmac
import random
import string
import time
from .error import KeyIdReplaced
@dataclass
class LanConfig:
lanip_key: str
lanip_key_id: int
random_1: str
time_1: int
random_2: str
time_2: int
@dataclass
class Encryption:
sign_key: bytes
crypto_key: bytes
iv_seed: bytes
cipher: AES
def __init__(self, lanip_key: bytes, msg: bytes):
self.sign_key = self._build_key(lanip_key, msg + b'0')
self.crypto_key = self._build_key(lanip_key, msg + b'1')
self.iv_seed = self._build_key(lanip_key, msg + b'2')[:AES.block_size]
self.cipher = AES.new(self.crypto_key, AES.MODE_CBC, self.iv_seed)
@classmethod
def _build_key(cls, lanip_key: bytes, msg: bytes) -> bytes:
return cls.hmac_digest(lanip_key, cls.hmac_digest(lanip_key, msg) + msg)
@staticmethod
def hmac_digest(key: bytes, msg: bytes) -> bytes:
return hmac.digest(key, msg, 'sha256')
@dataclass
class Config:
_lan_config: LanConfig
app: Encryption
dev: Encryption
def __init__(self, lanip_key: str, lanip_key_id: int):
self._lan_config = LanConfig(lanip_key, lanip_key_id, '', 0, '', 0)
self._update_encryption()
def update(self, key: dict):
"""Updates the stored lan config, and encryption data."""
self._lan_config.random_1 = key['random_1']
self._lan_config.time_1 = key['time_1']
if key['key_id'] != self._lan_config.lanip_key_id:
raise KeyIdReplaced(
'The key_id has been replaced!!',
'Old ID was {}; new ID is {}.'.format(self._lan_config.lanip_key_id, key['key_id']))
self._lan_config.random_2 = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
self._lan_config.time_2 = time.monotonic_ns()
self._update_encryption()
return {'random_2': self._lan_config.random_2, 'time_2': self._lan_config.time_2}
def _update_encryption(self):
lanip_key = self._lan_config.lanip_key.encode('utf-8')
random_1 = self._lan_config.random_1.encode('utf-8')
random_2 = self._lan_config.random_2.encode('utf-8')
time_1 = str(self._lan_config.time_1).encode('utf-8')
time_2 = str(self._lan_config.time_2).encode('utf-8')
self.app = Encryption(lanip_key, random_1 + random_2 + time_1 + time_2)
self.dev = Encryption(lanip_key, random_2 + random_1 + time_2 + time_1)

104
legacy/control_value.py Normal file
View File

@@ -0,0 +1,104 @@
from .properties import (AcWorkMode, AirFlow, Economy, FanSpeed, FastColdHeat, Quiet, Power,
TemperatureUnit)
def clear_up_change_flags(control: int) -> int:
return control & 2868817502
def get_fan_speed(control: int) -> FanSpeed:
int_val = (control >> 1) & 15
return FanSpeed(int_val)
def set_fan_speed(control: int, value: FanSpeed) -> None:
int_val = value.value
return (control & ~31) | ((int_val << 1) | 1)
def get_power(control: int) -> Power:
int_val = (control >> 6) & 1
return Power(int_val)
def set_power(control: int, value: Power) -> None:
int_val = value.value
return (control & ~(3 << 5)) | (((int_val << 1) | 1) << 5)
def get_work_mode(control: int) -> AcWorkMode:
int_val = (control >> 9) & 7
return AcWorkMode(int_val)
def set_work_mode(control: int, value: AcWorkMode) -> None:
int_val = value.value
return (control & ~(15 << 8)) | (((int_val << 1) | 1) << 8)
def get_heat_cold(control: int) -> FastColdHeat:
int_val = (control >> 13) & 1
return FastColdHeat(int_val)
def set_heat_cold(control: int, value: FastColdHeat) -> None:
int_val = value.value
return (control & ~(3 << 12)) | (((int_val << 1) | 1) << 12)
def get_eco(control: int) -> Economy:
int_val = (control >> 15) & 1
return Economy(int_val)
def set_eco(control: int, value: Economy) -> None:
int_val = value.value
return (control & ~(3 << 14)) | (((int_val << 1) | 1) << 14)
def get_temp(control: int) -> int:
return (control >> 17) & 63
def set_temp(control: int, value: int) -> None:
return (control & ~(127 << 16)) | (((value << 1) | 1) << 16)
def get_fan_power(control: int) -> AirFlow:
int_val = (control >> 25) & 1
return AirFlow(int_val)
def set_fan_power(control: int, value: AirFlow) -> None:
int_val = value.value
return (control & ~(3 << 24)) | (((int_val << 1) | 1) << 24)
def get_fan_lr(control: int) -> AirFlow:
int_val = (control >> 27) & 1
return AirFlow(int_val)
def set_fan_lr(control: int, value: AirFlow) -> None:
int_val = value.value
return (control & ~(3 << 26)) | (((int_val << 1) | 1) << 26)
def get_fan_mute(control: int) -> Quiet:
int_val = (control >> 29) & 1
return Quiet(int_val)
def set_fan_mute(control: int, value: Quiet) -> None:
int_val = value.value
return (control & ~(3 << 28)) | (((int_val << 1) | 1) << 28)
def get_temptype(control: int) -> TemperatureUnit:
int_val = (control >> 31) & 1
return TemperatureUnit(int_val)
def set_temptype(control: int, value: TemperatureUnit) -> None:
int_val = value.value
return (control & ~(3 << 30)) | (((int_val << 1) | 1) << 30)

170
legacy/discovery.py Normal file
View File

@@ -0,0 +1,170 @@
import aiohttp
import base64
from getmac import get_mac_address
from http import HTTPStatus
import json
import logging
import ssl
import sys
from .app_mappings import *
_USER_AGENT = 'Dalvik/2.1.0 (Linux; U; Android 9.0; SM-G850F Build/LRX22G)'
async def _sign_in(user: str, passwd: str, user_server: str, app_id: str, app_secret: str,
session: aiohttp.ClientSession, ssl_context: ssl.SSLContext):
query = {
'user': {
'email': user,
'password': passwd,
'application': {
'app_id': app_id,
'app_secret': app_secret
}
}
}
headers = {
'Accept': 'application/json',
'Connection': 'Keep-Alive',
'Authorization': 'none',
'Content-Type': 'application/json',
'User-Agent': _USER_AGENT,
'Host': user_server,
'Accept-Encoding': 'gzip'
}
logging.debug('POST /users/sign_in.json, body=%r, headers=%r', json.dumps(query), headers)
async with session.request('POST',
f'https://{user_server}/users/sign_in.json',
json=query,
headers=headers,
ssl=ssl_context) as resp:
if resp.status != HTTPStatus.OK.value:
logging.error('Failed to login to Hisense server:\nStatus %d: %r', resp.status, resp.reason)
sys.exit(1)
resp_data = await resp.text()
try:
tokens = json.loads(resp_data)
except UnicodeDecodeError:
logging.exception('Failed to parse login tokens to Hisense server:\nData: %r', resp_data)
sys.exit(1)
return tokens['access_token']
async def _get_devices(devices_server: str, access_token: str, headers: dict,
session: aiohttp.ClientSession, ssl_context: ssl.SSLContext):
logging.debug('GET /apiv1/devices.json, headers=%r', headers)
async with session.get(f'https://{devices_server}/apiv1/devices.json',
headers=headers,
ssl=ssl_context) as resp:
if resp.status != HTTPStatus.OK.value:
logging.error('Failed to get devices data from Hisense server:\nStatus %d: %r', resp.status,
resp.reason)
sys.exit(1)
resp_data = await resp.text()
try:
devices = json.loads(resp_data)
except UnicodeDecodeError:
logging.exception('Failed to parse devices data from Hisense server:\nData: %r', resp_data)
sys.exit(1)
if not devices:
logging.error('No device is configured! Please configure a device first.')
sys.exit(1)
return devices
async def _get_lanip(devices_server: str, dsn: str, headers: dict, session: aiohttp.ClientSession,
ssl_context: ssl.SSLContext):
logging.debug(f'GET /apiv1/dsns/{dsn}/lan.json, headers=%r', headers)
async with session.get(f'https://{devices_server}/apiv1/dsns/{dsn}/lan.json',
headers=headers,
ssl=ssl_context) as resp:
if resp.status != HTTPStatus.OK.value:
logging.error('Failed to get device data from Hisense server: %r', resp)
sys.exit(1)
resp_data = await resp.text()
return json.loads(resp_data)['lanip']
async def _get_device_properties(devices_server: str, dsn: str, headers: dict,
session: aiohttp.ClientSession, ssl_context: ssl.SSLContext):
logging.debug(f'GET /apiv1/dsns/{dsn}/properties.json, headers=%r', headers)
async with session.get(f'https://{devices_server}/apiv1/dsns/{dsn}/properties.json',
headers=headers,
ssl=ssl_context) as resp:
if resp.status != HTTPStatus.OK.value:
logging.error('Failed to get properties data from Hisense server: %r', resp)
sys.exit(1)
resp_data = await resp.text()
return json.loads(resp_data)
async def perform_discovery(session: aiohttp.ClientSession,
app: str,
user: str,
passwd: str,
device_filter: str = None,
properties_filter: bool = False) -> dict:
if app in SECRET_ID_MAP:
app_prefix = SECRET_ID_MAP[app]
else:
app_prefix = 'a-Hisense-{}-field'.format(app)
if app in SECRET_ID_EXTRA_MAP:
app_id = '-'.join((app_prefix, SECRET_ID_EXTRA_MAP[app], 'id'))
else:
app_id = '-'.join((app_prefix, 'id'))
secret = base64.b64encode(SECRET_MAP[app]).decode('utf-8').rstrip('=').replace('+', '-').replace(
'/', '_')
app_secret = '-'.join((app_prefix, secret))
# Extract the region from the app ID (and fallback to US)
region = app[-2:]
if region not in AYLA_USER_SERVERS:
region = 'us'
user_server = AYLA_USER_SERVERS[region]
devices_server = AYLA_DEVICES_SERVERS[region]
ssl_context = ssl.SSLContext()
ssl_context.verify_mode = ssl.CERT_NONE
ssl_context.check_hostname = False
ssl_context.load_default_certs()
access_token = await _sign_in(user, passwd, user_server, app_id, app_secret, session, ssl_context)
result = []
headers = {
'Accept': 'application/json',
'Connection': 'Keep-Alive',
'Authorization': 'auth_token ' + access_token,
'User-Agent': _USER_AGENT,
'Host': devices_server,
'Accept-Encoding': 'gzip'
}
devices = await _get_devices(devices_server, access_token, headers, session, ssl_context)
logging.debug('Found devices: %r', devices)
for device in devices:
device_data = device['device']
if device_filter and device_filter != device_data['product_name']:
continue
dsn = device_data['dsn']
lanip = await _get_lanip(devices_server, dsn, headers, session, ssl_context)
properties_text = ''
if properties_filter:
props = await _get_device_properties(devices_server, dsn, headers, session, ssl_context)
device_data['properties'] = props
device_data['lanip_key'] = lanip['lanip_key']
device_data['lanip_key_id'] = lanip['lanip_key_id']
device_data['temp_type'] = 'C' if app in CELSIUS_BASED_APPS else 'F'
# If the server doesn't know the MAC address, fetch it from the local network.
if not device_data.get('mac'):
mac = get_mac_address(ip=device_data['lan_ip'])
if not mac or mac == '00:00:00:00:00:00':
logging.error(f'Failed to fetch MAC address for AC on IP address {device_data["lan_ip"]}.' +
'\nAre you sure it is connected? Skipping...')
continue
device_data['mac'] = mac.replace(':', '')
result.append(device_data)
return result

11
legacy/error.py Normal file
View File

@@ -0,0 +1,11 @@
class Error(Exception):
"""Error class for AC handling."""
pass
class KeyIdReplaced(Exception):
"""Error class for key id replacement"""
def __init__(self, title, message):
self.title = title
self.message = message

314
legacy/main.py Normal file
View File

@@ -0,0 +1,314 @@
import aiohttp
from aiohttp import web
import argparse
import asyncio
import base64
from http import HTTPStatus
from http.client import HTTPConnection, InvalidURL
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import logging
import logging.handlers
import os
import paho.mqtt.client as mqtt
from retry import retry
import signal
import socket
import sys
try:
from systemd.journal import JournalHandler
except:
JournalHandler = None
import textwrap
import threading
import time
import _thread
from urllib.parse import parse_qs, urlparse, ParseResult
from aircon.app_mappings import SECRET_MAP
from aircon.config import Config
from aircon.error import Error
from aircon.aircon import Device
from aircon.discovery import perform_discovery
from aircon.mqtt_client import MqttClient
from aircon.notifier import Notifier
from aircon.query_handlers import QueryHandlers
async def query_status_device(device: Device):
_STATUS_UPDATE_INTERVAL = 600.0
_WAIT_FOR_EMPTY_QUEUE = 10.0
while True:
# In case the AC is stuck, and not fetching commands, avoid flooding
# the queue with status updates.
while device.commands_queue.qsize() > 10:
await asyncio.sleep(_WAIT_FOR_EMPTY_QUEUE)
device.queue_status()
await asyncio.sleep(_STATUS_UPDATE_INTERVAL)
async def query_status_worker(devices: [Device]):
await asyncio.wait([asyncio.create_task(query_status_device(device)) for device in devices])
def ParseArguments() -> argparse.Namespace:
"""Parse command line arguments."""
arg_parser = argparse.ArgumentParser(description='JSON server for HiSense air conditioners.',
allow_abbrev=False)
arg_parser.add_argument('--log_level',
default='WARNING',
choices={'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'},
help='Minimal log level.')
arg_parser.add_argument('--stderr',
default=False,
action='store_true',
help='Output log to stderr')
subparsers = arg_parser.add_subparsers(dest='cmd', help='Determines what server should do')
subparsers.required = True
parser_run = subparsers.add_parser('run', help='Runs the server to control the device')
parser_run.add_argument('-p', '--port', required=True, type=int, help='Port for the server.')
parser_run.add_argument('--local_ip',
required=False,
default=None,
help='The local IP address to report to the AC unit(s) as target server. Useful in case the server running this application has multiple IP addresses (e.g. in multiple VLANs), since some/most(?) AC units will refuse to report to an IP address outside of their subnet.')
group_device = parser_run.add_argument_group('Device', 'Arguments that are related to the device')
group_device.add_argument('--config', required=True, action='append', help='LAN Config file.')
group_device.add_argument('--type',
required=False,
action='append',
choices={'ac', 'fgl', 'fgl_b', 'humidifier'},
help='Device type. Deprecated, now decided based on OEM model.')
group_mqtt = parser_run.add_argument_group('MQTT', 'Settings related to the MQTT')
group_mqtt.add_argument('--mqtt_host', default=None, help='MQTT broker hostname or IP address.')
group_mqtt.add_argument('--mqtt_port', type=int, default=1883, help='MQTT broker port.')
group_mqtt.add_argument('--mqtt_client_id', default=None, help='MQTT client ID.')
group_mqtt.add_argument('--mqtt_user', default=None, help='<user:password> for the MQTT channel.')
group_mqtt.add_argument('--mqtt_topic', default='hisense_ac', help='MQTT topic.')
group_mqtt.add_argument('--mqtt_discovery_prefix',
default='homeassistant',
help='MQTT discovery prefix for HomeAssistant.')
parser_discovery = subparsers.add_parser('discovery', help='Runs the device discovery')
parser_discovery.add_argument('app', choices=set(SECRET_MAP), help='The app used for the login.')
parser_discovery.add_argument('user', help='Username for the app login.')
parser_discovery.add_argument('passwd', help='Password for the app login.')
parser_discovery.add_argument('-d',
'--device',
default=None,
help='Device name to fetch data for. If not set, takes all.')
parser_discovery.add_argument('--prefix',
required=False,
default='config_',
help='Config file prefix.')
parser_discovery.add_argument('--properties',
action='store_true',
help='Fetch the properties for the device.')
return arg_parser.parse_args()
def setup_logger(log_level, use_stderr=False):
if use_stderr or os.environ.get('PLATFORM') == 'docker':
logging_handler = logging.StreamHandler(sys.stderr)
elif JournalHandler:
logging_handler = JournalHandler()
# Fallbacks when JournalHandler isn't available.
elif sys.platform == 'linux':
logging_handler = logging.handlers.SysLogHandler(address='/dev/log')
elif sys.platform == 'darwin':
logging_handler = logging.handlers.SysLogHandler(address='/var/run/syslog')
elif sys.platform.lower() in ['windows', 'win32']:
logging_handler = logging.handlers.SysLogHandler()
else: # Unknown platform, revert to stderr
logging_handler = logging.StreamHandler(sys.stderr)
logging_handler.setFormatter(
logging.Formatter(fmt='{levelname[0]}{asctime}.{msecs:03.0f} '
'{filename}:{lineno}] {message}',
datefmt='%m%d %H:%M:%S',
style='{'))
logger = logging.getLogger()
logger.setLevel(log_level)
logger.addHandler(logging_handler)
async def setup_and_run_http_server(parsed_args, devices: [Device]):
query_handlers = QueryHandlers(devices)
app = web.Application()
app.add_routes([
web.get('/hisense/status', query_handlers.get_status_handler),
web.get('/hisense/command', query_handlers.queue_command_handler),
web.post('/local_lan/key_exchange.json', query_handlers.key_exchange_handler),
web.get('/local_lan/commands.json', query_handlers.command_handler),
web.post('/local_lan/property/datapoint.json', query_handlers.property_update_handler),
web.post('/local_lan/property/datapoint/ack.json', query_handlers.property_update_handler),
web.post('/local_lan/node/property/datapoint.json', query_handlers.property_update_handler),
web.post('/local_lan/node/property/datapoint/ack.json',
query_handlers.property_update_handler),
# TODO: Handle these if needed.
# '/local_lan/node/conn_status.json': query_handlers.connection_status_handler,
# '/local_lan/connect_status': query_handlers.module_request_handler,
# '/local_lan/status.json': query_handlers.setup_device_details_handler,
# '/local_lan/wifi_scan.json': query_handlers.module_request_handler,
# '/local_lan/wifi_scan_results.json': query_handlers.module_request_handler,
# '/local_lan/wifi_status.json': query_handlers.module_request_handler,
# '/local_lan/regtoken.json': query_handlers.module_request_handler,
# '/local_lan/wifi_stop_ap.json': query_handlers.module_request_handler
])
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, port=parsed_args.port)
await site.start()
async def mqtt_loop(mqtt_client: MqttClient):
_MQTT_LOOP_TIMEOUT = 1
while True:
mqtt_client.loop()
await asyncio.sleep(_MQTT_LOOP_TIMEOUT)
async def run(parsed_args):
notifier = Notifier(parsed_args.port, parsed_args.local_ip)
devices = []
for i in range(len(parsed_args.config)):
with open(parsed_args.config[i], 'rb') as f:
config = json.load(f)
device = Device.create(config, notifier.notify)
notifier.register_device(device)
devices.append(device)
mqtt_client = None
if parsed_args.mqtt_host:
mqtt_topics = {
'pub':
'/'.join((parsed_args.mqtt_topic, '{}', '{}', 'status')),
'sub':
'/'.join((parsed_args.mqtt_topic, '{}', '{}', 'command')),
'lwt':
'/'.join((parsed_args.mqtt_topic, 'LWT')),
'discovery':
'/'.join((parsed_args.mqtt_discovery_prefix, 'climate', '{}', 'hvac', 'config'))
}
mqtt_client = MqttClient(parsed_args.mqtt_client_id, mqtt_topics, devices)
if parsed_args.mqtt_user:
mqtt_client.username_pw_set(*parsed_args.mqtt_user.split(':', 1))
mqtt_client.will_set(mqtt_topics['lwt'], payload='offline', retain=True)
mqtt_client.connect(parsed_args.mqtt_host, parsed_args.mqtt_port)
mqtt_client.publish(mqtt_topics['lwt'], payload='online', retain=True)
for device in devices:
config = {
'name': device.name,
'unique_id': device.mac_address,
'device': {
'identifiers': [f'hisense_ac_{device.mac_address}'],
'manufacturer': f'Hisense ({device.app})',
'model': device.model,
'name': device.name,
'sw_version': device.sw_version
},
'availability': [
{
'topic': mqtt_topics['lwt']
},
{
'topic': mqtt_topics['pub'].format(device.mac_address, 'available')
},
],
'precision': 1.0,
'temperature_unit': 'F' if device.is_fahrenheit else 'C'
}
topics = device.topics
if 'env_temp' in topics:
config['current_temperature_topic'] = mqtt_topics['pub'].format(
device.mac_address, topics['env_temp'])
if 'fan_speed' in topics:
config['fan_mode_command_topic'] = mqtt_topics['sub'].format(device.mac_address,
topics['fan_speed'])
config['fan_mode_state_topic'] = mqtt_topics['pub'].format(device.mac_address,
topics['fan_speed'])
config['fan_modes'] = device.fan_modes
if 'work_mode' in topics:
config['mode_command_topic'] = mqtt_topics['sub'].format(device.mac_address,
topics['work_mode'])
config['mode_state_topic'] = mqtt_topics['pub'].format(device.mac_address,
topics['work_mode'])
config['modes'] = device.work_modes
if 'swing_mode' in topics:
config['swing_mode_command_topic'] = mqtt_topics['sub'].format(
device.mac_address, topics['swing_mode'])
config['swing_mode_state_topic'] = mqtt_topics['pub'].format(device.mac_address,
topics['swing_mode'])
config['swing_modes'] = ['on', 'off']
if 'temp' in topics:
config['temperature_command_topic'] = mqtt_topics['sub'].format(
device.mac_address, topics['temp'])
config['temperature_state_topic'] = mqtt_topics['pub'].format(device.mac_address,
topics['temp'])
config['max_temp'] = '86' if device.is_fahrenheit else '30'
config['min_temp'] = '61' if device.is_fahrenheit else '16'
if 'display_temperature' in topics:
config['display_temperature_state_topic'] = mqtt_topics['pub'].format(device.mac_address,
topics['display_temperature'])
mqtt_client.publish(mqtt_topics['discovery'].format(device.mac_address),
payload=json.dumps(config),
retain=True)
device.add_property_change_listener(mqtt_client.mqtt_publish_update)
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(connect=5.0)) as session:
await asyncio.gather(mqtt_loop(mqtt_client), setup_and_run_http_server(parsed_args, devices),
query_status_worker(devices), notifier.start(session))
def _escape_name(name: str):
safe_name = name.replace(' ', '_').lower()
return ''.join(x for x in safe_name if x.isalnum())
async def discovery(parsed_args):
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(connect=5.0)) as session:
try:
all_configs = await perform_discovery(session, parsed_args.app, parsed_args.user,
parsed_args.passwd, parsed_args.device,
parsed_args.properties)
except Exception as e:
print(f'Error occurred:\n{e!r}')
sys.exit(1)
for config in all_configs:
properties_text = ''
if 'properties' in config.keys():
properties_text = f'Properties:\n{json.dumps(config["properties"], indent=2)}'
print(
textwrap.dedent(f"""Device {config['product_name']} has:
IP address: {config['lan_ip']}
lanip_key: {config['lanip_key']}
lanip_key_id: {config['lanip_key_id']}
{properties_text}
"""))
file_content = {
'name': config['product_name'],
'app': parsed_args.app,
'model': config['oem_model'],
'sw_version': config['sw_version'],
'dsn': config['dsn'],
'temp_type': config['temp_type'],
'mac_address': config['mac'],
'ip_address': config['lan_ip'],
'lanip_key': config['lanip_key'],
'lanip_key_id': config['lanip_key_id'],
}
with open(parsed_args.prefix + _escape_name(config['product_name']) + '.json', 'w') as f:
f.write(json.dumps(file_content))
if __name__ == '__main__':
parsed_args = ParseArguments() # type: argparse.Namespace
if parsed_args.cmd == 'run':
setup_logger(parsed_args.log_level, use_stderr=parsed_args.stderr)
asyncio.run(run(parsed_args))
elif parsed_args.cmd == 'discovery':
setup_logger(parsed_args.log_level, use_stderr=True)
asyncio.run(discovery(parsed_args))

92
legacy/mqtt_client.py Normal file
View File

@@ -0,0 +1,92 @@
from dataclasses import fields
import enum
import logging
import paho.mqtt.client as mqtt
from .aircon import Device
from .properties import AcWorkMode, FglOperationMode
class MqttClient(mqtt.Client):
def __init__(self, client_id: str, mqtt_topics: dict, devices: [Device]):
super().__init__(client_id=client_id, clean_session=True)
self._mqtt_topics = mqtt_topics
self._devices = devices
self.on_connect = self.mqtt_on_connect
self.on_message = self.mqtt_on_message
def mqtt_on_connect(self, client: mqtt.Client, userdata, flags, rc):
for device in self._devices:
topics_fmt = [(self._mqtt_topics['sub'].format(device.mac_address, data_field.name), 0)
for data_field in fields(device.get_all_properties())]
logging.debug(f"Subscribing to topics{topics_fmt} for device {device}")
client.subscribe(topics_fmt)
# Subscribe to subscription updates.
client.subscribe('$SYS/broker/log/M/subscribe/#')
# Publish current status of all properties for available devices.
for device in self._devices:
if device.available:
for prop_name in fields(device.get_all_properties()):
self.mqtt_publish_update(device.mac_address,
prop_name,
device.get_property(prop_name),
retain=False)
def mqtt_on_message(self, client: mqtt.Client, userdata, message: mqtt.MQTTMessage):
logging.info('MQTT message Topic: {}, Payload {}'.format(message.topic, message.payload))
if message.topic.startswith('$SYS/broker/log/M/subscribe'):
return self.mqtt_on_subscribe(message.payload)
mac_address = message.topic.rsplit('/', 3)[1]
prop_name = message.topic.rsplit('/', 3)[2]
payload = message.payload.decode('utf-8')
if prop_name == 't_work_mode':
if payload == 'fan_only':
payload = 'FAN'
for device in self._devices:
if device.mac_address != mac_address:
continue
chosen_device = device
try:
chosen_device.queue_command(prop_name, payload.upper())
except Exception:
logging.exception('Failed to parse value {} for property {}'.format(
payload.upper(), prop_name))
def mqtt_on_subscribe(self, payload: bytes):
# The last segment in the space delimited string is the topic.
topic = payload.decode('utf-8').rsplit(' ', 1)[-1]
if topic not in self._mqtt_topics['pub']:
return
mac_address = topic.rsplit('/', 3)[1]
prop_name = topic.rsplit('/', 3)[2]
for device in self._devices:
if device.mac_address != mac_address:
continue
chosen_device = device
self.mqtt_publish_update(chosen_device.mac_address,
prop_name,
chosen_device.get_property(prop_name),
retain=False)
def mqtt_publish_update(self,
mac_address: str,
property_name: str,
value,
retain: bool = False) -> None:
if isinstance(value, enum.Enum):
payload = 'fan_only' if (value is AcWorkMode.FAN or
value is FglOperationMode.FAN_ONLY) else value.name.lower()
else:
payload = str(value)
topic = self._mqtt_topics['pub'].format(mac_address, property_name)
logging.info('Sending MQTT update Topic: {}, Payload {}'.format(topic, payload))
self.publish(topic,
payload=payload.encode('utf-8'),
retain=retain)

126
legacy/notifier.py Normal file
View File

@@ -0,0 +1,126 @@
import aiohttp
import asyncio
import concurrent
from dataclasses import dataclass
from http import HTTPStatus
import json
import logging
import socket
import sys
from tenacity import retry, retry_if_exception_type, wait_exponential, stop_after_attempt
import time
import threading
from .aircon import Device
if sys.version_info < (3, 8):
TimeoutError = concurrent.futures.TimeoutError
else:
TimeoutError = asyncio.exceptions.TimeoutError
@dataclass
class _NotifyConfiguration:
device: Device
headers: dict
last_timestamp: int
def _run_after_failure(retry_state):
config = retry_state.kwargs['config']
config.device.available = False
return 0
class Notifier:
_KEEP_ALIVE_INTERVAL = 1200.0
_TIME_TO_HANDLE_REQUESTS = 60.0
def __init__(self, port: int, local_ip: str):
self._configurations = []
self._condition = asyncio.Condition()
self._running = False
local_ip = local_ip or self._get_local_ip()
self._json = {'local_reg': {'ip': local_ip, 'notify': 0, 'port': port, 'uri': '/local_lan'}}
def _get_local_ip(self):
sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.connect(('10.255.255.255', 1))
return sock.getsockname()[0]
finally:
if sock:
sock.close()
def register_device(self, device: Device):
if device not in (conf.device for conf in self._configurations):
headers = {
'Accept': 'application/json',
'Connection': 'keep-alive',
'Content-Type': 'application/json',
'Host': device.ip_address,
'Accept-Encoding': 'gzip'
}
self._configurations.append(_NotifyConfiguration(device, headers, 0))
async def _notify(self):
async with self._condition:
self._condition.notify_all()
def notify(self):
loop = asyncio.get_event_loop()
asyncio.run_coroutine_threadsafe(self._notify(), loop)
async def start(self, session: aiohttp.ClientSession):
self._running = True
async with self._condition:
while self._running:
queue_sizes = await asyncio.gather(*(self._perform_request(session=session, config=config)
for config in self._configurations))
if max(queue_sizes) <= 1:
logging.debug('[KeepAlive] Waiting for notification or timeout')
try:
await asyncio.wait_for(self._condition.wait(), timeout=self._KEEP_ALIVE_INTERVAL)
except TimeoutError:
pass
else:
# give some time to clean up the queues
await asyncio.sleep(self._TIME_TO_HANDLE_REQUESTS)
async def stop(self):
self._running = False
await self._notify()
@retry(retry=retry_if_exception_type(ConnectionError),
retry_error_callback=_run_after_failure,
wait=wait_exponential(exp_base=1.6, max=10),
stop=stop_after_attempt(6))
async def _perform_request(self, session: aiohttp.ClientSession,
config: _NotifyConfiguration) -> int:
now = time.time()
queue_size = config.device.commands_queue.qsize()
if (queue_size == 0 or
not config.device.available) and now - config.last_timestamp < self._KEEP_ALIVE_INTERVAL:
return 0
method = 'PUT' if config.device.available else 'POST'
self._json['local_reg']['notify'] = int(config.device.commands_queue.qsize() > 0)
url = f'http://{config.device.ip_address}/local_reg.json'
logging.debug(f'[KeepAlive] Sending {method} {url} {json.dumps(self._json)}')
try:
async with session.request(method, url, json=self._json, headers=config.headers) as resp:
if resp.status != HTTPStatus.ACCEPTED.value:
resp_data = await resp.text()
logging.error(f'[KeepAlive] Sending local_reg failed: {resp.status}, {resp_data}')
raise ConnectionError(f'Sending local_reg failed: {resp.status}, {resp_data}')
except (aiohttp.client_exceptions.ClientConnectorError,
aiohttp.client_exceptions.ClientConnectionError) as e:
logging.error(f'Failed to connect to {config.device.ip_address}, maybe it is offline?')
raise ConnectionError(
f'Failed to connect to {config.device.ip_address}, maybe it is offline?')
config.last_timestamp = now
config.device.available = True
return queue_size

525
legacy/properties.py Normal file
View File

@@ -0,0 +1,525 @@
from dataclasses import dataclass, field
from dataclasses_json import dataclass_json
import enum
class AirFlowState(enum.IntEnum):
OFF = 0
VERTICAL_ONLY = 1
HORIZONTAL_ONLY = 2
VERTICAL_AND_HORIZONTAL = 3
class FanSpeed(enum.IntEnum):
AUTO = 0
LOWER = 5
LOW = 6
MEDIUM = 7
HIGH = 8
HIGHER = 9
class SleepMode(enum.IntEnum):
STOP = 0
ONE = 1
TWO = 2
THREE = 3
FOUR = 4
class StateMachine(enum.IntEnum):
FANONLY = 0
HEAT = 1
COOL = 2
DRY = 3
AUTO = 4
FAULTSHIELD = 5
POWEROFF = 6
OFFLINE = 7
READONLYSHARED = 8
class AcWorkMode(enum.IntEnum):
FAN = 0
HEAT = 1
COOL = 2
DRY = 3
AUTO = 4
class AirFlow(enum.Enum):
OFF = 0
ON = 1
class DeviceErrorStatus(enum.Enum):
NORMALSTATE = 0
FAULTSTATE = 1
class Dimmer(enum.Enum):
ON = 0
OFF = 1
class DoubleFrequency(enum.Enum):
OFF = 0
ON = 1
class Economy(enum.Enum):
OFF = 0
ON = 1
class EightHeat(enum.Enum):
OFF = 0
ON = 1
class FastColdHeat(enum.Enum):
OFF = 0
ON = 1
class Power(enum.Enum):
OFF = 0
ON = 1
class Quiet(enum.Enum):
OFF = 0
ON = 1
class TemperatureUnit(enum.Enum):
CELSIUS = 0
FAHRENHEIT = 1
class HumidifierWorkMode(enum.Enum):
NORMAL = 0
NIGHTLIGHT = 1
SLEEP = 2
class HumidifierWater(enum.Enum):
OK = 0
NO_WATER = 1
class Mist(enum.Enum):
SMALL = 1
MIDDLE = 2
BIG = 3
class MistState(enum.Enum):
OFF = 0
ON = 1
class FglOperationMode(enum.IntEnum):
OFF = 0
ON = 1
AUTO = 2
COOL = 3
DRY = 4
FAN_ONLY = 5
HEAT = 6
class FglFanSpeed(enum.IntEnum):
QUIET = 0
LOW = 1
MEDIUM = 2
HIGH = 3
AUTO = 4
class Properties(object):
@classmethod
def _get_metadata(cls, attr: str):
return cls.__dataclass_fields__[attr].metadata
@classmethod
def get_type(cls, attr: str):
return cls.__dataclass_fields__[attr].type
@classmethod
def parse_attr(cls, attr, value):
"""If a field supplies a parser function in its metadata, use it to parse its value from the raw data."""
# Retrieve the desired type from the class attribute type hinting
native_type = cls.__dataclass_fields__[attr].type
value_fmt = native_type(value)
# Detect parser for this attribute
parser = cls.__dataclass_fields__[attr].metadata.get('parser')
if parser:
value_fmt = parser(value)
return value_fmt
@classmethod
def get_base_type(cls, attr: str):
return cls._get_metadata(attr)['base_type']
@classmethod
def get_precision(cls, attr: str):
return cls._get_metadata(attr).get('precision', 1)
@classmethod
def get_update_precision(cls, attr: str):
metadata = cls._get_metadata(attr)
return metadata.get('update_precision', metadata.get('precision', 1))
@classmethod
def get_read_only(cls, attr: str):
return cls._get_metadata(attr)['read_only']
@dataclass_json
@dataclass
class AcProperties(Properties):
# ack_cmd: bool = field(default=None, metadata={'base_type': 'boolean', 'read_only': False})
f_electricity: int = field(default=100, metadata={'base_type': 'integer', 'read_only': True})
f_e_arkgrille: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_incoiltemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_incom: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_indisplay: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_ineeprom: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_inele: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_infanmotor: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_inhumidity: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_inkeys: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_inlow: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_intemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_invzero: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outcoiltemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outeeprom: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outgastemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outmachine2: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outmachine: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outtemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outtemplow: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_push: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_filterclean: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_humidity: int = field(default=50, metadata={
'base_type': 'integer',
'read_only': True
}) # Humidity
f_power_display: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_temp_in: float = field(default=81.0, metadata={
'base_type': 'decimal',
'read_only': True
}) # EnvironmentTemperature (Fahrenheit)
f_voltage: int = field(default=0, metadata={'base_type': 'integer', 'read_only': True})
t_backlight: Dimmer = field(default=Dimmer.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Dimmer[x]
}
}) # DimmerStatus
t_control_value: int = field(default=None, metadata={'base_type': 'integer', 'read_only': False})
t_device_info: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': False})
t_display_power: bool = field(default=None, metadata={'base_type': 'boolean', 'read_only': False})
t_eco: Economy = field(default=Economy.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Economy[x]
}
})
t_fan_leftright: AirFlow = field(default=AirFlow.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: AirFlow[x]
}
}) # HorizontalAirFlow
t_fan_mute: Quiet = field(default=Quiet.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Quiet[x]
}
}) # QuietModeStatus
t_fan_power: AirFlow = field(default=AirFlow.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: AirFlow[x]
}
}) # VerticalAirFlow
t_fan_speed: FanSpeed = field(default=FanSpeed.AUTO,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: FanSpeed[x]
}
}) # FanSpeed
t_ftkt_start: int = field(default=None, metadata={'base_type': 'integer', 'read_only': False})
t_power: Power = field(default=Power.ON,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Power[x]
}
}) # PowerStatus
t_run_mode: DoubleFrequency = field(default=DoubleFrequency.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: DoubleFrequency[x]
}
}) # DoubleFrequency
t_setmulti_value: int = field(default=None, metadata={'base_type': 'integer', 'read_only': False})
t_sleep: SleepMode = field(default=SleepMode.STOP,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: SleepMode[x]
}
}) # SleepMode
t_temp: int = field(default=81, metadata={
'base_type': 'integer',
'read_only': False
}) # CurrentTemperature
t_temptype: TemperatureUnit = field(default=TemperatureUnit.FAHRENHEIT,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: TemperatureUnit[x]
}
}) # CurrentTemperatureUnit
t_temp_eight: EightHeat = field(default=EightHeat.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: EightHeat[x]
}
}) # EightHeatStatus
t_temp_heatcold: FastColdHeat = field(default=FastColdHeat.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: FastColdHeat[x]
}
}) # FastCoolHeatStatus
t_work_mode: AcWorkMode = field(default=AcWorkMode.AUTO,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: AcWorkMode[x]
}
}) # WorkModeStatus
@dataclass_json
@dataclass
class HumidifierProperties(Properties):
humi: int = field(default=0, metadata={'base_type': 'integer', 'read_only': False})
mist: Mist = field(default=Mist.SMALL,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Mist[x]
}
})
mistSt: MistState = field(default=MistState.OFF,
metadata={
'base_type': 'integer',
'read_only': True,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: MistState[x]
}
})
realhumi: int = field(default=0, metadata={'base_type': 'integer', 'read_only': True})
remain: int = field(default=0, metadata={'base_type': 'integer', 'read_only': True})
switch: Power = field(default=Power.ON,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Power[x]
}
})
temp: int = field(default=81, metadata={'base_type': 'integer', 'read_only': True})
timer: int = field(default=-1, metadata={'base_type': 'integer', 'read_only': False})
water: HumidifierWater = field(default=HumidifierWater.OK,
metadata={
'base_type': 'boolean',
'read_only': True,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: HumidifierWater[x]
}
})
workmode: HumidifierWorkMode = field(default=HumidifierWorkMode.NORMAL,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: HumidifierWorkMode[x]
}
})
@dataclass_json
@dataclass
class FglProperties(Properties):
operation_mode: FglOperationMode = field(default=FglOperationMode.AUTO,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: FglOperationMode[x]
}
})
fan_speed: FglFanSpeed = field(default=FglFanSpeed.AUTO,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: FglFanSpeed[x]
}
})
adjust_temperature: int = field(default=25,
metadata={
'base_type': 'integer',
'precision': 0.1,
'update_precision': 1.0,
'read_only': False
})
display_temperature: float = field(default=25,
metadata={
'base_type': 'integer',
'read_only': True,
'parser': lambda x: round((x-5000)/50)/2,
})
af_vertical_direction: int = field(default=3,
metadata={
'base_type': 'integer',
'read_only': False
})
af_vertical_swing: AirFlow = field(default=AirFlow.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: AirFlow[x]
}
}) # HorizontalAirFlow
af_horizontal_direction: int = field(default=3,
metadata={
'base_type': 'integer',
'read_only': False
})
af_horizontal_swing: AirFlow = field(default=AirFlow.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: AirFlow[x]
}
}) # HorizontalAirFlow
economy_mode: Economy = field(default=Economy.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Economy[x]
}
})
@dataclass_json
@dataclass
class FglBProperties(Properties):
operation_mode: FglOperationMode = field(default=FglOperationMode.AUTO,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: FglOperationMode[x]
}
})
fan_speed: FglFanSpeed = field(default=FglFanSpeed.AUTO,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: FglFanSpeed[x]
}
})
adjust_temperature: int = field(default=25,
metadata={
'base_type': 'integer',
'precision': 0.1,
'read_only': False
})
display_temperature: float = field(default=25,
metadata={
'base_type': 'float',
'read_only': True,
'parser': lambda x: round((x-5000)/50)/2,
})
af_vertical_move_step1: int = field(default=3,
metadata={
'base_type': 'integer',
'read_only': False
})
af_horizontal_move_step1: int = field(default=3,
metadata={
'base_type': 'integer',
'read_only': False
})
economy_mode: Economy = field(default=Economy.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Economy[x]
}
})

157
legacy/query_handlers.py Normal file
View File

@@ -0,0 +1,157 @@
from aiohttp import web
import base64
from Crypto.Cipher import AES
from http import HTTPStatus
import json
import math
import logging
import queue
import random
import string
import time
from typing import Callable
from .config import Config, Encryption
from .aircon import Device
from .error import Error, KeyIdReplaced
class QueryHandlers:
def __init__(self, devices: [Device]):
self._devices_map = {}
for device in devices:
self._devices_map[device.ip_address] = device
async def key_exchange_handler(self, request: web.Request) -> web.Response:
"""Handles a key exchange.
Accepts the AC's random and time and pass its own.
Note that a key encryption component is the lanip_key, mapped to the
lanip_key_id provided by the AC. This secret part is provided by HiSense
server. Fortunately the lanip_key_id (and lanip_key) are static for a given
AC.
"""
updated_keys = {}
post_data = await request.text()
print(post_data)
data = json.loads(post_data)
try:
key = data['key_exchange']
if key['ver'] != 1 or key['proto'] != 1 or key.get('sec'):
logging.error(f'Invalid key exchange: {data}')
raise web.HTTPBadRequest(reason=f'Invalid key exchange: {data}')
updated_keys = self._devices_map[request.remote].update_key(key)
except KeyIdReplaced as e:
logging.error(f'{e.title}\n{e.message}')
return web.Response(status=HTTPStatus.NOT_FOUND.value, reason=f'{e.title}\n{e.message}')
print(updated_keys)
return web.json_response(updated_keys)
async def command_handler(self, request: web.Request) -> web.Response:
"""Handles a command request.
Request arrives from the AC. takes a command from the queue,
builds the JSON, encrypts and signs it, and sends it to the AC.
"""
command = {}
device = self._devices_map[request.remote]
command['seq_no'] = device.get_command_seq_no()
try:
command_entry = device.commands_queue.get_nowait()
command['data'], property_updater = command_entry.command, command_entry.updater
except queue.Empty:
command['data'], property_updater = {}, None
if property_updater:
property_updater() #TODO: should be async as well?
return web.json_response(self._encrypt_and_sign(device, command))
async def property_update_handler(self, request: web.Request) -> web.Response:
"""Handles a property update request.
Decrypts, validates, and pushes the value into the local properties store.
"""
device = self._devices_map[request.remote]
post_data = await request.text()
data = json.loads(post_data)
try:
update = self._decrypt_and_validate(device, data)
except Error:
logging.exception('Failed to parse property.')
return web.Response(status=HTTPStatus.BAD_REQUEST.value, reason='Failed to parse property.')
response = web.Response()
if not device.is_update_valid(update['seq_no']):
return response
try:
if not update['data']:
logging.info('Unsupported update message = {}'.format(update['seq_no']))
return response
name = update['data']['name']
# Fix A/C typos.
if name == 'f_votage':
name = 'f_voltage'
value = device.parse_property(name, update['data']['value'])
logging.debug(f"Updating {device}.{name} to {value} ({update['data']['value']})")
device.update_property(name, value)
logging.debug(f"Updated{device}: {device.get_all_properties()})")
except Exception as ex:
logging.error('Failed to handle {}. Exception = {}'.format(update, ex))
#TODO: Should return internal error?
return response
async def get_status_handler(self, request: web.Request) -> web.Response:
"""Handles get status request (by a smart home hub).
Returns the current internally stored state of the AC.
"""
devices = []
for device in self._devices_map.values():
if 'device_ip' in request.query.keys() and device.ip_address != request.query['device_ip']:
continue
devices.append({'ip': device.ip_address, 'props': device.get_all_properties().to_dict()})
return web.json_response({'devices': devices})
async def queue_command_handler(self, request: web.Request) -> web.Response:
"""Handles queue command request (by a smart home hub).
"""
device = self._devices_map.get(request.query.get('device_ip'))
if not device:
if len(self._devices_map) == 1:
device = list(self._devices_map.values())[0]
else:
raise web.HTTPBadRequest(reason=f'Device "{request.query.get("device_ip")}" not found.')
try:
device.queue_command(request.query['property'], request.query['value'])
except Exception as ex:
logging.exception('Failed to queue command.')
raise web.HTTPBadRequest(f'Failed to queue command:\n{ex!r}')
return web.json_response({'queued_commands': device.commands_queue.qsize()})
def _encrypt_and_sign(self, device: Device, data: dict) -> dict:
text = json.dumps(data)
logging.debug('Encrypting: {}'.format(text))
text = text.encode('utf-8')
encryption = device.get_app_encryption()
return {
"enc": base64.b64encode(encryption.cipher.encrypt(self.pad(text))).decode('utf-8'),
"sign": base64.b64encode(Encryption.hmac_digest(encryption.sign_key, text)).decode('utf-8')
}
def _decrypt_and_validate(self, device: Device, data: dict) -> dict:
encryption = device.get_dev_encryption()
text = self.unpad(encryption.cipher.decrypt(base64.b64decode(data['enc'])))
sign = base64.b64encode(Encryption.hmac_digest(encryption.sign_key, text)).decode('utf-8')
if sign != data['sign']:
raise Error(f'Invalid signature for:\n{text.decode("utf-8", errors="backslashreplace")}!')
logging.debug('Decrypted: %s', text.decode('utf-8'))
try:
return json.loads(text.decode('utf-8'))
except Exception as ex:
raise Error(f'Failed to decode message, {ex!r}:\n{text.decode("utf-8")}')
@staticmethod
def pad(data: bytes):
"""Zero padding for AES data encryption (non standard)."""
new_size = math.ceil(len(data) / AES.block_size) * AES.block_size
return data.ljust(new_size, bytes([0]))
@staticmethod
def unpad(data: bytes):
"""Remove Zero padding for AES data encryption (non standard)."""
return data.rstrip(bytes([0]))

7
legacy/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
aiohttp==3.13.1
dataclasses_json
pycryptodome
paho-mqtt==1.6.1
tenacity
get-mac
retry

14
legacy/run.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
set -e
. /opt/aircon/.venv/bin/activate
python /opt/aircon/main.py \
--log_level DEBUG \
--stderr \
run \
--port 3355 \
--mqtt_host 192.168.88.1 \
--mqtt_user "aircon:ic6ahrahbaijaVe3eTip" \
--local_ip 192.168.88.1 \
--config /opt/aircon/config_kata.json \
"$@"

83
tools/probe_mdns.py Normal file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Пассивная mDNS-проба модуля кондиционера: A-запрос <DSN>.local
на 224.0.0.251:5353 и :10276 (нестандартный порт Ayla из APK)."""
import socket, struct, sys, time
DSN = "AC000W00REDACTED"
HOST = DSN + ".local"
def qname(name):
out = b""
for lbl in name.split("."):
out += bytes([len(lbl)]) + lbl.encode()
return out + b"\x00"
def query_packet(txid):
# standard query, RD=1, one question: A, class IN
header = struct.pack(">HHHHHH", txid, 0x0100, 1, 0, 0, 0)
return header + qname(HOST) + struct.pack(">HH", 1, 1)
def parse_name(buf, off):
parts = []
while True:
l = buf[off]
if l == 0:
off += 1
break
if l & 0xC0 == 0xC0:
ptr = struct.unpack(">H", buf[off:off+2])[0] & 0x3FFF
parts.append(parse_name(buf, ptr)[0])
off += 2
break
parts.append(buf[off+1:off+1+l].decode("latin1"))
off += 1 + l
return ".".join(parts), off
def parse_answers(buf):
try:
qd = struct.unpack(">H", buf[4:6])[0]
an = struct.unpack(">H", buf[6:8])[0]
off = 12
for _ in range(qd):
_, off = parse_name(buf, off)
off += 4
found = []
for _ in range(an):
name, off = parse_name(buf, off)
rtype, rclass, ttl, rdlen = struct.unpack(">HHIH", buf[off:off+10])
off += 10
rdata = buf[off:off+rdlen]
if rtype == 1 and rdlen == 4:
ip = ".".join(str(b) for b in rdata)
found.append((name, ip, rclass, ttl))
off += rdlen
return found
except Exception as e:
return [("parse-error", str(e), 0, 0)]
def probe(port, txid, wait=4.0):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("", 0))
pkt = query_packet(txid)
for _ in range(3):
s.sendto(pkt, ("224.0.0.251", port))
time.sleep(0.5)
s.settimeout(wait)
try:
while True:
data, addr = s.recvfrom(4096)
print(f" [:{port}] from {addr}: {len(data)} bytes")
for rec in parse_answers(data):
print(f" {rec}")
except socket.timeout:
pass
finally:
s.close()
if __name__ == "__main__":
print("mDNS A?", HOST)
print("-- querying :5353")
probe(5353, 0x1234)
print("-- querying :10276")
probe(10276, 0x1235)

268
tools/probe_reference.py Normal file
View File

@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Эталонный клиент LAN-протокола FGLair (сторона «приложения»).
Проверен на реальном модуле AP-WC1E (fw 2.6.17-fgl2). Соответствует
docs/PROTOCOL.md, включая поведение, подтверждённое живыми тестами:
- keep-alive каждые 15 c (модуль сам инициирует re-key при возрасте
сессии >= ~44 c — это штатная ротация, обрабатывается прозрачно);
- 206/200 в commands.json, NUL-паддинг (Java-вариант);
- 401 при ошибке расшифровки, 412 при несовпадении key_id;
- записи не эхируются: оптимистичное обновление + GET-подтверждение;
- delete_session при выходе (освобождает один из 2 слотов модуля).
Использование:
python probe_reference.py config_kata.json monitor 60
python probe_reference.py config_kata.json get operation_mode fan_speed
python probe_reference.py config_kata.json set fan_speed 3
Зависимости: pycryptodome."""
import base64, hmac, json, random, socket, string, sys, threading, time
import http.client
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from Crypto.Cipher import AES
T0 = time.time()
log = lambda *a: print(f"+{time.time()-T0:7.1f}s", *a, flush=True)
def rand_token(n=16):
return "".join(random.choice(string.ascii_letters + string.digits) for _ in range(n))
class Crypto:
"""Ключи/цепочки по PROTOCOL.md §3. ВНИМАНИЕ: CBC-состояние непрерывно
в рамках сессии (одно сообщение — следующее продолжает цепочку)."""
def __init__(self, lanip_key, rnd1, rnd2, t1, t2):
k = lanip_key.encode()
b1, b2, s1, s2 = rnd1.encode(), rnd2.encode(), str(t1).encode(), str(t2).encode()
def m(msg, suf):
msg = msg + bytes([suf])
return hmac.digest(k, hmac.digest(k, msg, "sha256") + msg, "sha256")
A, D = b1 + b2 + s1 + s2, b2 + b1 + s2 + s1
self.app_sign, self.dev_sign = m(A, 0x30), m(D, 0x30)
self._e = AES.new(m(A, 0x31), AES.MODE_CBC, m(A, 0x32)[:16])
self._d = AES.new(m(D, 0x31), AES.MODE_CBC, m(D, 0x32)[:16])
self.seq = 0
def enc_sign(self, data) -> bytes:
self.seq += 1
raw = json.dumps({"seq_no": self.seq - 1, "data": data},
separators=(",", ":")).encode()
n = ((len(raw) + 1 + 15) // 16) * 16 # >=1 NUL, кратно 16
sign = base64.b64encode(hmac.digest(self.app_sign, raw, "sha256")).decode()
enc = base64.b64encode(self._e.encrypt(raw.ljust(n, b"\x00"))).decode()
return json.dumps({"enc": enc, "sign": sign}, separators=(",", ":")).encode()
def decrypt_validate(self, body: dict):
ptb = self._d.decrypt(base64.b64decode(body["enc"])).rstrip(b"\x00")
ok = base64.b64encode(hmac.digest(self.dev_sign, ptb, "sha256")).decode() == body.get("sign")
return ok, ptb
class ReferenceClient:
def __init__(self, cfg_path, port=10275, keepalive=15.0):
cfg = json.load(open(cfg_path))
self.ip, self.dsn = cfg["ip_address"], cfg["dsn"]
self.key, self.key_id = cfg["lanip_key"], cfg["lanip_key_id"]
self.port, self.keepalive = port, keepalive
self.crypto = None
self.queue = [] # [(payload, note)]
self.cmd_id = 0
self.props = {} # кэш значений
self.pushes = 0
self.online = threading.Event()
self.lock = threading.Lock()
self._ka_stop = threading.Event()
# ---------------- HTTP-сервер (входящие от модуля) ----------------
def _handler(self):
cli = self
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a): pass
def _body(self):
n = int(self.headers.get("Content-Length") or 0)
return self.rfile.read(n) if n else b""
def _send(self, code, body=b""):
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body: self.wfile.write(body)
def do_POST(self):
body, path = self._body(), self.path.split("?")[0]
if path == "/local_lan/key_exchange.json":
ke = json.loads(body)["key_exchange"]
if ke.get("ver") != 1 or ke.get("proto") != 1:
self._send(426, b'{"error":"Unsupported crypto version"}'); return
if ke.get("key_id") != cli.key_id:
log(f"KEY ROTATED: {ke.get('key_id')} != {cli.key_id} -> 412")
self._send(412, b'{"error":"Keys do not match"}'); return
rnd2, t2 = rand_token(), time.monotonic_ns()
cli.crypto = Crypto(cli.key, ke["random_1"], rnd2, ke["time_1"], t2)
log(f"KEY_EXCHANGE (re-key ok, random_1={ke['random_1']!r})")
self._send(200, json.dumps(
{"random_2": rnd2, "time_2": t2}).encode()); return
if path.endswith("/local_lan/property/datapoint.json"):
if cli.crypto is None:
self._send(401, b'{"error":"Decryption failed"}'); return
ok, ptb = cli.crypto.decrypt_validate(json.loads(body))
if not ok:
log("DATAPPOINT: подпись/расшифровка НЕ сошлись -> 401")
self._send(401, b'{"error":"Decryption failed"}'); return
cli.pushes += 1
try:
d = json.loads(ptb)["data"]
with cli.lock:
cli.props[d["name"]] = d.get("value")
log(f"PUSH {d['name']} = {d.get('value')}"
f" (query={self.path.split('?', 1)[-1] or '-'})")
except Exception as e:
log("PUSH parse error:", e)
self._send(200); return
if path.endswith("/ack.json"):
log("ACK", body[:120]); self._send(200); return
self._send(404)
def do_GET(self):
if self.path.split("?")[0] == "/local_lan/commands.json":
if cli.crypto is None:
self._send(401, b'{"error":"Decryption failed"}'); return
with cli.lock:
payload, note = cli.queue.pop(0) if cli.queue else ({}, "empty")
rest = len(cli.queue)
code = 206 if rest else 200
log(f"COMMANDS -> {note} [{code}]")
self._send(code, cli.crypto.enc_sign(payload)); return
self._send(404)
return H
# ---------------- исходящие ----------------
def local_reg(self, notify, first=False):
method = "POST" if first else "PUT"
url = f"/local_reg.json" + (f"?dsn={self.dsn}" if first else "")
body = json.dumps({"local_reg": {"ip": self._my_ip(), "notify": 1 if notify else 0,
"port": self.port, "uri": "/local_lan"}})
c = http.client.HTTPConnection(self.ip, timeout=10)
try:
c.request(method, url, body=body, headers={
"Accept": "application/json", "Connection": "keep-alive",
"Content-Type": "application/json", "Accept-Encoding": "gzip"})
r = c.getresponse(); r.read()
if r.status == 503:
log("local_reg -> 503: нет свободных слотов (заняты 2 сессии)")
return r.status
finally:
c.close()
@staticmethod
def _my_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("10.255.255.255", 1)); return s.getsockname()[0]
finally:
s.close()
def queue_get(self, prop):
with self.lock:
self.cmd_id += 1
cid = self.cmd_id
self.queue.append(({"cmds": [{"cmd": {
"method": "GET", "resource": "property.json?name=" + prop,
"uri": "/local_lan/property/datapoint.json", "data": "",
"cmd_id": cid}}]}, f"GET {prop}"))
def queue_set(self, prop, value):
with self.lock:
self.queue.append(({"properties": [{"property": {
"base_type": "integer", "name": prop, "value": value,
"id": rand_token(8)}}]}, f"SET {prop}={value}"))
self.props[prop] = value # оптимистично: эха нет
def queue_delete_session(self):
with self.lock:
self.queue.append(({"cmds": [{"cmd": {"cmd_id": 0, "method": "DELETE",
"resource": "local_reg.json", "data": "delete_session",
"uri": "/local_lan"}}]}, "DELETE session"))
# ---------------- жизненный цикл ----------------
def start(self, timeout=15):
srv = ThreadingHTTPServer(("0.0.0.0", self.port), self._handler())
srv.handle_error = lambda *a: None # RST от модуля — норма
threading.Thread(target=srv.serve_forever, daemon=True).start()
self._srv = srv
st = self.local_reg(notify=0, first=True)
t0 = time.time()
while time.time() - t0 < timeout:
if self.crypto and self.pushes >= 0 and self._activated:
break
time.sleep(0.05)
if not self._activated:
raise RuntimeError("сессия не активировалась (нет опроса commands.json после KE)")
threading.Thread(target=self._keepalive_loop, daemon=True).start()
self.online.set()
_activated = False
def notify_activation(self):
self._activated = True
def _keepalive_loop(self):
while not self._ka_stop.wait(self.keepalive):
try:
notify = bool(self.queue)
self.local_reg(notify=notify)
except Exception as e:
log("keep-alive error:", e)
def stop(self):
self._ka_stop.set()
try:
self.queue_delete_session()
self.local_reg(notify=True)
time.sleep(2)
except Exception:
pass
self._srv.shutdown()
# ------------------------------------------------------------------
def patch_activation(cli):
"""Активация = первый GET commands.json после KE."""
orig = cli._handler
def wrapper():
H = orig()
class H2(H):
def do_GET(self):
cli.notify_activation()
H.do_GET(self)
return H2
cli._handler = wrapper
def main():
if len(sys.argv) < 3:
print(__doc__); return
cfg, cmd = sys.argv[1], sys.argv[2]
cli = ReferenceClient(cfg)
patch_activation(cli)
cli.start()
log("сессия установлена")
try:
if cmd == "get":
for p in sys.argv[3:]:
cli.queue_get(p)
cli.local_reg(notify=True)
time.sleep(8)
elif cmd == "set":
prop, val = sys.argv[3], int(sys.argv[4])
cli.queue_set(prop, val)
time.sleep(0.3)
cli.local_reg(notify=True)
time.sleep(3)
cli.queue_get(prop) # GET-подтверждение (эха нет)
cli.local_reg(notify=True)
time.sleep(5)
elif cmd == "monitor":
for p in ("operation_mode", "fan_speed", "adjust_temperature",
"display_temperature", "wifi_led_enable"):
cli.queue_get(p)
cli.local_reg(notify=True)
time.sleep(int(sys.argv[3]) if len(sys.argv) > 3 else 60)
log("кэш свойств:", json.dumps(cli.props, ensure_ascii=False))
finally:
cli.stop()
log("сессия закрыта (delete_session)")
if __name__ == "__main__":
main()