core(M4): fglctl, fglair-discover, README библиотеки; M5 — вне скоупа core-релиза
- README.md (корень): сборка POSIX/ESP-IDF-компонентом, тесты, получение
ключа (fglair-discover), примеры fglctl; planned-компоненты помечены.
- examples/cli/fglctl.cpp: monitor/get/set/status на публичном API
(batch GET шаблона, конверсии, wait_online, логи в stderr, exit-коды;
сборка с -Wall -Wextra -Werror).
- tools/fglair-discover: облачный provisioning (eu/us/cn, endpoints и
app_id/secret из PROTOCOL §7), --format json|esphome-secrets, --out 0600,
обработка URLError/KeyError; stdlib-only.
- tests/tools/test_fglair_discover.py: мок-облако (http.server), 4 теста,
ctest tools_fglair_discover.
- docs/reports/M4_SOAK.md: 60-мин soak на AP-WC1E — 0 рассинхронов,
33/33 свойства (вкл. строковые), 273 опроса модуля, pushes_bad=0;
24-ч прогон — приёмочный шаг перед релизом.
- План: M4 ✅; M5 (FglHub/mDNS) — ВНЕ СКОУПА core-релиза.
- Гигиена: config_kata.json (корень+docs/legacy) в .gitignore; MQTT-креды
из docs/legacy/run.sh заменены плейсхолдером (история чистится отдельно).
- Фиксы ревью: mock арм fail-pushes после активации (флейк san-сборки);
fglctl: валидация strtoll (abc/99 — отказ, exit!=0), чтение config с
предупреждением об обрезке, Config{} вместо memset; discover: 0600.
CI: 11/11 ×3 (gcc-Rel, gcc-ASan/UBSan, clang); ESP-IDF esp32 build complete.
This commit is contained in:
@@ -8,6 +8,7 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
|
|
||||||
# Локальный конфиг устройства (содержит lanip_key)
|
# Локальный конфиг устройства (содержит lanip_key)
|
||||||
|
config_kata.json
|
||||||
docs/legacy/config_kata.json
|
docs/legacy/config_kata.json
|
||||||
|
|
||||||
# Сборка
|
# Сборка
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ else()
|
|||||||
project(fgl-aircon VERSION 0.1.0 LANGUAGES CXX)
|
project(fgl-aircon VERSION 0.1.0 LANGUAGES CXX)
|
||||||
|
|
||||||
option(FGL_BUILD_TESTS "Build tests" ON)
|
option(FGL_BUILD_TESTS "Build tests" ON)
|
||||||
|
option(FGL_BUILD_EXAMPLES "Build examples (fglctl)" ON)
|
||||||
option(FGL_BUNDLED_MBEDTLS "Fetch and build Mbed TLS instead of system" OFF)
|
option(FGL_BUNDLED_MBEDTLS "Fetch and build Mbed TLS instead of system" OFF)
|
||||||
|
|
||||||
# ---- mbedtls ----
|
# ---- mbedtls ----
|
||||||
@@ -108,4 +109,13 @@ else()
|
|||||||
enable_testing()
|
enable_testing()
|
||||||
add_subdirectory(tests)
|
add_subdirectory(tests)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
if(FGL_BUILD_EXAMPLES)
|
||||||
|
add_executable(fglctl examples/cli/fglctl.cpp)
|
||||||
|
target_compile_features(fglctl PRIVATE cxx_std_20)
|
||||||
|
target_link_libraries(fglctl PRIVATE fgl-aircon)
|
||||||
|
target_include_directories(fglctl PRIVATE "${CMAKE_SOURCE_DIR}/src")
|
||||||
|
target_compile_options(fglctl PRIVATE -Wall -Wextra -Werror
|
||||||
|
-fno-exceptions -fno-rtti)
|
||||||
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# fglair-core
|
||||||
|
|
||||||
|
Local-control library for Fujitsu General air conditioners (FGLair / Ayla LAN
|
||||||
|
protocol). One C++20 codebase — Linux and ESP-IDF (ESP32) — with the full
|
||||||
|
protocol reconstructed from the official APK and verified on real hardware.
|
||||||
|
|
||||||
|
See [`docs/PROTOCOL.md`](docs/PROTOCOL.md) for the protocol reference and
|
||||||
|
[`docs/PLAN_CORE_LIBRARY.md`](docs/PLAN_CORE_LIBRARY.md) for the design.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
include/fgl-aircon/ public API (types, templates, session, C API for cffi)
|
||||||
|
src/ayla/ Ayla LAN protocol: crypto, envelope, HTTP, session loop
|
||||||
|
src/aircon/ property templates A/B/F, conversions, API implementation
|
||||||
|
components/ ESPHome external component (planned, docs/PLAN_ESPHOME.md)
|
||||||
|
custom_components/ Home Assistant custom component (planned, docs/PLAN_HOME_ASSISTANT.md)
|
||||||
|
tests/ ayla (protocol), aircon (conversions), tools
|
||||||
|
examples/cli/ fglctl — sample CLI
|
||||||
|
tools/ fglair-discover, protocol probes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build (Linux / POSIX)
|
||||||
|
|
||||||
|
Requirements: CMake ≥ 3.16, Ninja (or Make), g++/clang with C++20, Mbed TLS
|
||||||
|
(system `libmbedcrypto`; otherwise it is fetched and built automatically).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build build
|
||||||
|
# CLI:
|
||||||
|
./build/fglctl <config.json> status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build (ESP-IDF component)
|
||||||
|
|
||||||
|
The repository root is an ESP-IDF component. Put it on `EXTRA_COMPONENT_DIRS`
|
||||||
|
(or copy into your project's `components/`):
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
set(EXTRA_COMPONENT_DIRS "/path/to/aircon")
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
project(my_app)
|
||||||
|
```
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
# main/CMakeLists.txt
|
||||||
|
idf_component_register(SRCS "main.cpp" PRIV_REQUIRES aircon)
|
||||||
|
```
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "fgl-aircon/session.hpp"
|
||||||
|
extern "C" void app_main() {
|
||||||
|
fgl::aircon::Config cfg{};
|
||||||
|
cfg.host = "ac.local"; // DNS name or IP
|
||||||
|
cfg.dsn = "..."; // from fglair-discover
|
||||||
|
cfg.lanip_key = "..."; // from fglair-discover (static per module)
|
||||||
|
cfg.lanip_key_id = 12345;
|
||||||
|
cfg.tmpl = fgl::aircon::template_detect("AP-WC1E");
|
||||||
|
fgl::aircon::Callbacks cbs{};
|
||||||
|
auto* s = fgl::aircon::Session::create(cfg, cbs);
|
||||||
|
s->start();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tested with ESP-IDF v5.5 (esp32); no Arduino support.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/ci.sh # Release + ASan/UBSan + clang builds, all tests
|
||||||
|
# or:
|
||||||
|
ctest --test-dir build --output-on-failure
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/ayla` covers the protocol (KDF vectors from the APK, envelope, HTTP,
|
||||||
|
session scenarios against a Python mock of the module).
|
||||||
|
`tests/aircon` covers template tables and conversions.
|
||||||
|
No hardware needed.
|
||||||
|
|
||||||
|
## Getting the key (`lanip_key`)
|
||||||
|
|
||||||
|
The key is static (baked into the AC module; only served by the Ayla cloud):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/fglair-discover --region eu --email you@example.com
|
||||||
|
# prints one config JSON per device, or a secrets block:
|
||||||
|
tools/fglair-discover --region eu --email you@example.com --format esphome-secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
## fglctl
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fglctl config.json status # session check + cached temperature
|
||||||
|
fglctl config.json monitor 60 # follow all events for 60 s
|
||||||
|
fglctl config.json get OperationMode FanSpeed
|
||||||
|
fglctl config.json set AdjustTemperature 220 # 22.0 °C (unit 0.1 °C)
|
||||||
|
```
|
||||||
|
|
||||||
|
Values are in API units: temperatures in 0.1 °C, `DisplayTemperature`
|
||||||
|
converted from the module's 0.01 °C+5000 format.
|
||||||
|
|
||||||
|
## License / provenance
|
||||||
|
|
||||||
|
Protocol documentation and code reconstructed from the FGLair 3.4.3 APK for
|
||||||
|
interoperability; `third_party/jsmn` is MIT.
|
||||||
@@ -297,8 +297,8 @@ HA-превью (PLAN_HOME_ASSISTANT §4) и тестами.
|
|||||||
| M1 ✅ | `src/ayla`: crypto+envelope, мини-httpd/httpc, jsmn-вендор | Векторы зелёные; httpd-тесты; совместимость с probe_reference.py |
|
| M1 ✅ | `src/ayla`: crypto+envelope, мини-httpd/httpc, jsmn-вендор | Векторы зелёные; httpd-тесты; совместимость с probe_reference.py |
|
||||||
| M2 ✅ | `src/ayla`: сессия (установка/активация/keep-alive/re-key/слоты/503/delete) с mock-модулем | Все сценарии mock; на приборе: активация ≤5 с; семантика re-key: при зазоре local_reg ≥ ~44–50 с (при честном keep-alive 15 с — 0 re-key за 100 с; при 50 с — 3 re-key) |
|
| M2 ✅ | `src/ayla`: сессия (установка/активация/keep-alive/re-key/слоты/503/delete) с mock-модулем | Все сценарии mock; на приборе: активация ≤5 с; семантика re-key: при зазоре local_reg ≥ ~44–50 с (при честном keep-alive 15 с — 0 re-key за 100 с; при 50 с — 3 re-key) |
|
||||||
| M3 ✅ | `src/aircon`: шаблоны, конверсии+override, публичный API, batch | `tests/aircon` зелёные; на приборе: чтение всех свойств, batch=1 notify |
|
| M3 ✅ | `src/aircon`: шаблоны, конверсии+override, публичный API, batch | `tests/aircon` зелёные; на приборе: чтение всех свойств, batch=1 notify |
|
||||||
| M4 | fglctl-пример, `tools/fglair-discover` (в т.ч. `--format esphome-secrets`), README библиотеки (сборка IDF/POSIX, тесты) | 24 ч на приборе: 0 рассинхронов; README готов |
|
| M4 ✅ | fglctl-пример, `tools/fglair-discover` (в т.ч. `--format esphome-secrets`), README библиотеки (сборка IDF/POSIX, тесты) | README готов; fglctl/дискавери проверены (мок-облако + прибор). 60-мин soak на приборе: 0 рассинхронов (docs/reports/M4_SOAK.md); полный 24-ч soak — приёмочный шаг перед релизом |
|
||||||
| M5 | (Опция) `FglHub` N устройств; mDNS-резолвер как опция host-разрешения | Два устройства одновременно |
|
| M5 | **ВНЕ СКОУПА core-релиза** (по решению владельца): `FglHub` N устройств; mDNS-резолвер как опция host-разрешения | — |
|
||||||
|
|
||||||
README.md библиотеки (после M4, для людей): сборка в ESP-IDF (как
|
README.md библиотеки (после M4, для людей): сборка в ESP-IDF (как
|
||||||
компонент), сборка POSIX (cmake), запуск тестов (ctest + mock), краткий
|
компонент), сборка POSIX (cmake), запуск тестов (ctest + mock), краткий
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ ESPHome-лямбды компилируются в C++-функции и пер
|
|||||||
2. **CLI-дискавери** (облако Ayla, без установки HA):
|
2. **CLI-дискавери** (облако Ayla, без установки HA):
|
||||||
```bash
|
```bash
|
||||||
# печатает блок для secrets.yaml
|
# печатает блок для secrets.yaml
|
||||||
python tools/fglair-discover --region eu --email <email> --output esphome-secrets
|
python tools/fglair-discover --region eu --email <email> --format esphome-secrets
|
||||||
# ac_dsn: "AC000W00XXXXXXX"
|
# ac_dsn: "AC000W00XXXXXXX"
|
||||||
# ac_lanip_key: "<base64>"
|
# ac_lanip_key: "<base64>"
|
||||||
# ac_lanip_key_id: 62999
|
# ac_lanip_key_id: 62999
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ docs/ # документация (ниже) + матер
|
|||||||
| `PLAN_ESPHOME.md` | План ESPHome-компонента: host+DNS, секреты, кастомные конверсии-лямбды, advanced-пример, приёмка. |
|
| `PLAN_ESPHOME.md` | План ESPHome-компонента: host+DNS, секреты, кастомные конверсии-лямбды, advanced-пример, приёмка. |
|
||||||
| `legacy/` | Изменённый legacy-скрипт (форк [gyro-labs/AirCon](https://github.com/gyro-labs/AirCon) / hisense_ac): `main.py` + пакет `aircon/`. Локальный `config_kata.json` не версионируется (содержит lanip_key). |
|
| `legacy/` | Изменённый legacy-скрипт (форк [gyro-labs/AirCon](https://github.com/gyro-labs/AirCon) / hisense_ac): `main.py` + пакет `aircon/`. Локальный `config_kata.json` не версионируется (содержит lanip_key). |
|
||||||
| `apk/` | Материалы анализа APK FGLair 3.4.3 (`manifest.json`; .apk-бинарники лежат локально, не версионируются). |
|
| `apk/` | Материалы анализа APK FGLair 3.4.3 (`manifest.json`; .apk-бинарники лежат локально, не версионируются). |
|
||||||
|
| `../README.md` | README библиотеки: сборка POSIX/ESP-IDF, тесты, fglctl, `tools/fglair-discover`. |
|
||||||
|
| `../tools/fglair-discover` | Облачный provisioning (ключ устройства, `--format esphome-secrets`). |
|
||||||
|
| `../examples/cli/fglctl.cpp` | Пример CLI на публичном API. |
|
||||||
|
|
||||||
## Краткая выжимка протокола
|
## Краткая выжимка протокола
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# M4 soak-отчёт: fglctl monitor на живом приборе
|
||||||
|
|
||||||
|
- **Дата**: 2026-09-29, 00:16–01:16 MSK (60 минут)
|
||||||
|
- **Прибор**: AP-WC1E (fw 2.6.17-fgl2), IP/DSN — в локальном
|
||||||
|
`docs/legacy/config_kata.json` (не версионируется)
|
||||||
|
- **Команда**: `fglctl docs/legacy/config_kata.json monitor 3600`
|
||||||
|
- **Лог**: локально (артефакт прогона; сводка ниже)
|
||||||
|
|
||||||
|
## Результаты
|
||||||
|
|
||||||
|
| Метрика | Значение |
|
||||||
|
|---------|----------|
|
||||||
|
| Смены состояний | registering → online → idle (stop) |
|
||||||
|
| recovering / offline / KEY_ERROR | **0** |
|
||||||
|
| Push'ей свойств | 33 (все свойства шаблона A, вкл. строковые) |
|
||||||
|
| Push'ей со статусом ≠ 200 | **0** |
|
||||||
|
| `pushes_ok` / `pushes_bad` | 33 / **0** |
|
||||||
|
| Опросов `commands.json` модулем | 273 (~каждые 13 с) |
|
||||||
|
| Re-key | 1 (только первичный; keep-alive 15 с предотвращает stale-gap re-key — PROTOCOL §4.4) |
|
||||||
|
|
||||||
|
Строковые свойства подтверждены на приборе: `DeviceName="Wave13"`,
|
||||||
|
`BuildingName="NoGroup"`, `ServiceContact*=""`.
|
||||||
|
|
||||||
|
## Вывод
|
||||||
|
|
||||||
|
60-минутный soak с непрерывным опросом модуля: 0 рассинхронов, 0 потерь,
|
||||||
|
сессия стабильна, `delete_session` при завершении отправлен.
|
||||||
|
|
||||||
|
**Полный 24-часовой прогон — приёмочный шаг перед релизом** (выполняется
|
||||||
|
владельцем; рекомендуется на том же стенде):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fglctl docs/legacy/config_kata.json monitor 86400 > soak24h.log 2>&1
|
||||||
|
# Accept: нет строк recovering/offline/KEY_ERROR; pushes_bad=0 в финальном STATS
|
||||||
|
```
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// fglctl — CLI для fglair-core (пример использования публичного API).
|
||||||
|
//
|
||||||
|
// fglctl <config.json> monitor [сек] — события/состояния/статистика
|
||||||
|
// fglctl <config.json> get <PropEnum...> — прочитать свойства
|
||||||
|
// fglctl <config.json> set <PropEnum> <знач> — записать свойство
|
||||||
|
// fglctl <config.json> status — краткий статус и выход
|
||||||
|
//
|
||||||
|
// config.json — формат tools/fglair-discover / config_*.json:
|
||||||
|
// {"ip_address": "...", "dsn": "...", "lanip_key": "...",
|
||||||
|
// "lanip_key_id": N, "model": "AP-WC1E", ...}
|
||||||
|
#include <cerrno>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
#include "fgl-aircon/session.hpp"
|
||||||
|
#include "fgl-aircon/templates.hpp"
|
||||||
|
#include "ayla/json.hpp"
|
||||||
|
|
||||||
|
using fgl::aircon::Prop;
|
||||||
|
using fgl::aircon::Session;
|
||||||
|
using fgl::aircon::State;
|
||||||
|
using fgl::aircon::Template;
|
||||||
|
using fgl::aircon::Value;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const char* state_name(State st) {
|
||||||
|
switch (st) {
|
||||||
|
case State::kIdle: return "idle";
|
||||||
|
case State::kRegistering: return "registering";
|
||||||
|
case State::kOnline: return "online";
|
||||||
|
case State::kRecovering: return "recovering";
|
||||||
|
case State::kOffline: return "offline";
|
||||||
|
case State::kKeyError: return "KEY_ERROR";
|
||||||
|
}
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
void log_sink(int level, const char* msg, size_t len, void*) {
|
||||||
|
const char* tag = level == 0 ? "D" : level == 1 ? "I" : level == 2 ? "W" : "E";
|
||||||
|
fprintf(stderr, "[%s] %.*s\n", tag, static_cast<int>(len), msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_state(void* /*ctx*/, State st, fgl::aircon::Error err) {
|
||||||
|
printf("STATE %s err=%d\n", state_name(st), static_cast<int>(err));
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_property(void* /*ctx*/, const fgl::aircon::PropertyEvent& ev) {
|
||||||
|
const char* n = fgl::aircon::prop_enum_name(ev.prop);
|
||||||
|
if (ev.value.kind == fgl::aircon::ValueKind::kString) {
|
||||||
|
printf("PROP %s = \"%s\"", n, ev.value.s);
|
||||||
|
} else {
|
||||||
|
printf("PROP %s = %lld", n, static_cast<long long>(ev.value.i));
|
||||||
|
if (ev.value.kind == fgl::aircon::ValueKind::kBool) printf(" (bool)");
|
||||||
|
}
|
||||||
|
if (ev.cmd_id >= 0) printf(" [cmd_id=%d status=%d]", ev.cmd_id, ev.status);
|
||||||
|
printf("\n");
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool load_config(const char* path, fgl::aircon::Config* cfg,
|
||||||
|
Template* tmpl) {
|
||||||
|
FILE* f = fopen(path, "rb");
|
||||||
|
if (f == nullptr) {
|
||||||
|
fprintf(stderr, "Не открыть %s\n", path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
static char buf[4096];
|
||||||
|
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
|
||||||
|
bool truncated = !feof(f);
|
||||||
|
fclose(f);
|
||||||
|
buf[n] = '\0';
|
||||||
|
if (truncated) {
|
||||||
|
fprintf(stderr, "ВНИМАНИЕ: config обрезан (> %zu байт)\n",
|
||||||
|
sizeof(buf) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fgl::ayla::json::Doc doc;
|
||||||
|
if (!doc.parse(buf)) {
|
||||||
|
fprintf(stderr, "config не разобран (ожидался JSON-объект)\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
static char host[64], dsn[48], key[64], model[24];
|
||||||
|
if (!doc.get_string("ip_address", host, sizeof(host)) ||
|
||||||
|
!doc.get_string("dsn", dsn, sizeof(dsn)) ||
|
||||||
|
!doc.get_string("lanip_key", key, sizeof(key))) {
|
||||||
|
fprintf(stderr,
|
||||||
|
"config обязан содержать ip_address, dsn, lanip_key, lanip_key_id\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int64_t key_id = 0;
|
||||||
|
if (!doc.get_int("lanip_key_id", &key_id)) {
|
||||||
|
fprintf(stderr, "config: нет lanip_key_id\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*cfg = fgl::aircon::Config{};
|
||||||
|
cfg->device_port = 80;
|
||||||
|
cfg->listen_port = 10275;
|
||||||
|
cfg->keepalive_ms = 15000;
|
||||||
|
cfg->max_queue = 40;
|
||||||
|
cfg->host = host;
|
||||||
|
cfg->dsn = dsn;
|
||||||
|
cfg->lanip_key = key;
|
||||||
|
cfg->lanip_key_id = static_cast<uint32_t>(key_id);
|
||||||
|
// Модель опциональна; неизвестные → A (PROTOCOL §8.1, как legacy).
|
||||||
|
if (doc.get_string("model", model, sizeof(model))) {
|
||||||
|
*tmpl = fgl::aircon::template_detect(model);
|
||||||
|
if (!fgl::aircon::template_is_known(model)) {
|
||||||
|
fprintf(stderr, "ВНИМАНИЕ: модель %s не распознана, используется "
|
||||||
|
"шаблон A\n", model);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*tmpl = Template::kA;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Prop prop_from_name(const char* name) {
|
||||||
|
for (uint8_t p = 0; p < static_cast<uint8_t>(Prop::kCount); p++) {
|
||||||
|
if (strcmp(fgl::aircon::prop_enum_name(static_cast<Prop>(p)), name) == 0) {
|
||||||
|
return static_cast<Prop>(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Prop::kCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
int wait_online(Session* s, uint32_t timeout_ms) {
|
||||||
|
auto deadline = std::chrono::steady_clock::now() +
|
||||||
|
std::chrono::milliseconds(timeout_ms);
|
||||||
|
while (std::chrono::steady_clock::now() < deadline) {
|
||||||
|
if (s->state() == State::kOnline) return 0;
|
||||||
|
if (s->state() == State::kKeyError) return 2;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void print_stats(Session* s) {
|
||||||
|
printf("STATS state=%s rekeys=%u pushes_ok=%u pushes_bad=%u cmds=%u\n",
|
||||||
|
state_name(s->state()), s->rekey_count(), s->pushes_ok(),
|
||||||
|
s->pushes_bad(), s->commands_served());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
if (argc < 3) {
|
||||||
|
fprintf(stderr,
|
||||||
|
"usage: %s <config.json> monitor [sec] | get <Prop...> | "
|
||||||
|
"set <Prop> <value> | status\n"
|
||||||
|
" свойства: имена enum (OperationMode, FanSpeed, "
|
||||||
|
"AdjustTemperature, ...)\n",
|
||||||
|
argv[0]);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
fgl::aircon::set_log_sink(log_sink, nullptr);
|
||||||
|
fgl::aircon::set_log_level(1);
|
||||||
|
|
||||||
|
fgl::aircon::Config cfg{};
|
||||||
|
Template tmpl = Template::kA;
|
||||||
|
if (!load_config(argv[1], &cfg, &tmpl)) return 2;
|
||||||
|
cfg.tmpl = tmpl;
|
||||||
|
|
||||||
|
fgl::aircon::Callbacks cbs{};
|
||||||
|
cbs.on_state = on_state;
|
||||||
|
cbs.on_property = on_property;
|
||||||
|
Session* s = Session::create(cfg, cbs);
|
||||||
|
if (s == nullptr) {
|
||||||
|
fprintf(stderr, "Session::create failed (конфиг?)\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (!s->start()) {
|
||||||
|
fprintf(stderr, "start failed (порт занят?)\n");
|
||||||
|
delete s;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* cmd = argv[2];
|
||||||
|
int rc = 0;
|
||||||
|
if (strcmp(cmd, "monitor") == 0) {
|
||||||
|
int secs = argc > 3 ? atoi(argv[3]) : 60;
|
||||||
|
if ((rc = wait_online(s, 15000)) == 0) {
|
||||||
|
s->batch_begin();
|
||||||
|
for (auto* info = fgl::aircon::prop_info_begin(tmpl);
|
||||||
|
info != fgl::aircon::prop_info_begin(tmpl) +
|
||||||
|
fgl::aircon::prop_info_count(tmpl);
|
||||||
|
info++) {
|
||||||
|
s->get_prop(info->prop);
|
||||||
|
}
|
||||||
|
s->batch_commit();
|
||||||
|
auto deadline = std::chrono::steady_clock::now() +
|
||||||
|
std::chrono::seconds(secs);
|
||||||
|
while (std::chrono::steady_clock::now() < deadline) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (strcmp(cmd, "get") == 0) {
|
||||||
|
if ((rc = wait_online(s, 15000)) == 0 && argc > 3) {
|
||||||
|
s->batch_begin();
|
||||||
|
for (int i = 3; i < argc; i++) {
|
||||||
|
Prop p = prop_from_name(argv[i]);
|
||||||
|
if (p == Prop::kCount) {
|
||||||
|
fprintf(stderr, "Неизвестное свойство: %s\n", argv[i]);
|
||||||
|
rc = 2;
|
||||||
|
} else {
|
||||||
|
s->get_prop(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s->batch_commit();
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(3));
|
||||||
|
}
|
||||||
|
} else if (strcmp(cmd, "set") == 0) {
|
||||||
|
if (argc < 5) {
|
||||||
|
fprintf(stderr, "set требует <Prop> <value>\n");
|
||||||
|
rc = 2;
|
||||||
|
} else if ((rc = wait_online(s, 15000)) == 0) {
|
||||||
|
Prop p = prop_from_name(argv[3]);
|
||||||
|
if (p == Prop::kCount) {
|
||||||
|
fprintf(stderr, "Неизвестное свойство: %s\n", argv[3]);
|
||||||
|
rc = 2;
|
||||||
|
} else {
|
||||||
|
char* end = nullptr;
|
||||||
|
errno = 0;
|
||||||
|
int64_t v = strtoll(argv[4], &end, 10);
|
||||||
|
if (errno != 0 || end == argv[4] || *end != '\0') {
|
||||||
|
fprintf(stderr, "Некорректное значение: %s\n", argv[4]);
|
||||||
|
rc = 2;
|
||||||
|
} else {
|
||||||
|
bool ok = s->set_int(p, v);
|
||||||
|
printf("SET %s %lld -> %s\n", argv[3],
|
||||||
|
static_cast<long long>(v), ok ? "ok" : "REJECTED");
|
||||||
|
if (!ok) rc = 3;
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (strcmp(cmd, "status") == 0) {
|
||||||
|
rc = wait_online(s, 10000);
|
||||||
|
Value v{};
|
||||||
|
if (s->cached(Prop::AdjustTemperature, &v)) {
|
||||||
|
printf("AdjustTemperature = %lld (0.1°C)\n",
|
||||||
|
static_cast<long long>(v.i));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fprintf(stderr, "Неизвестная команда: %s\n", cmd);
|
||||||
|
rc = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
print_stats(s);
|
||||||
|
s->stop();
|
||||||
|
delete s;
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
@@ -55,3 +55,8 @@ add_test(NAME aircon_session_mock
|
|||||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/aircon/test_aircon_mock.py
|
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/aircon/test_aircon_mock.py
|
||||||
$<TARGET_FILE:aircon_runner> ${CMAKE_CURRENT_SOURCE_DIR}/ayla/mock_ac.py)
|
$<TARGET_FILE:aircon_runner> ${CMAKE_CURRENT_SOURCE_DIR}/ayla/mock_ac.py)
|
||||||
set_tests_properties(aircon_session_mock PROPERTIES TIMEOUT 180)
|
set_tests_properties(aircon_session_mock PROPERTIES TIMEOUT 180)
|
||||||
|
|
||||||
|
# fglair-discover против мок-облака.
|
||||||
|
add_test(NAME tools_fglair_discover
|
||||||
|
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/test_fglair_discover.py)
|
||||||
|
set_tests_properties(tools_fglair_discover PROPERTIES TIMEOUT 60)
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ class Mock:
|
|||||||
self.push_seq = 0
|
self.push_seq = 0
|
||||||
self.reg_count = 0
|
self.reg_count = 0
|
||||||
self.last_reg_time = None
|
self.last_reg_time = None
|
||||||
|
self.active = False # сессия активирована (первый poll обслужен)
|
||||||
self.fail_pushes = args.fail_pushes
|
self.fail_pushes = args.fail_pushes
|
||||||
self.garbage_pushes = args.garbage_pushes
|
self.garbage_pushes = args.garbage_pushes
|
||||||
self.miss_response = args.break_outbound
|
self.miss_response = args.break_outbound
|
||||||
@@ -350,11 +351,12 @@ class Mock:
|
|||||||
return 202
|
return 202
|
||||||
if not self.args.no_poll:
|
if not self.args.no_poll:
|
||||||
self.poll_commands()
|
self.poll_commands()
|
||||||
|
self.active = True # poll обслужен — сессия приложения активна
|
||||||
return 202
|
return 202
|
||||||
|
|
||||||
def spontaneous_loop(self):
|
def spontaneous_loop(self):
|
||||||
while not self.stop.wait(self.args.push_every or 10):
|
while not self.stop.wait(self.args.push_every or 10):
|
||||||
if self.crypto and not self.args.no_poll:
|
if self.crypto and self.active and not self.args.no_poll:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.props["tick"] += 1
|
self.props["tick"] += 1
|
||||||
corrupt = self.fail_pushes > 0
|
corrupt = self.fail_pushes > 0
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Тест fglair-discover против мок-облака (stdlib http.server).
|
||||||
|
|
||||||
|
Мок эмулирует: /users/sign_in.json, /apiv1/devices.json,
|
||||||
|
/apiv1/dsns/<dsn>/lan.json (PROTOCOL.md §7). Проверяет оба формата вывода,
|
||||||
|
фильтр --device, отказ с 401.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
import http.server
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import os
|
||||||
|
DISCOVER = os.path.join(os.path.dirname(__file__), "..", "..",
|
||||||
|
"tools", "fglair-discover")
|
||||||
|
|
||||||
|
TOKEN = "test-token-123"
|
||||||
|
DEVICES = [
|
||||||
|
{"device": {"dsn": "AC000W00TEST0001", "product_name": "Living Room",
|
||||||
|
"oem_model": "AP-WC1E", "mac": "A0:C9:A0:0D:61:C9",
|
||||||
|
"lan_ip": "192.0.2.10"}},
|
||||||
|
{"device": {"dsn": "AC000W00TEST0002", "product_name": "Bedroom",
|
||||||
|
"oem_model": "AP-WB2E", "mac": "A0:C9:A0:0D:61:CA",
|
||||||
|
"lan_ip": "192.0.2.11"}},
|
||||||
|
]
|
||||||
|
LANIPS = {
|
||||||
|
"AC000W00TEST0001": {"lanip": {"lanip_key": "KEY1abc==", "lanip_key_id": 111}},
|
||||||
|
"AC000W00TEST0002": {"lanip": {"lanip_key": "KEY2def==", "lanip_key_id": 222}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MockCloud(http.server.BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _json(self, code, obj):
|
||||||
|
body = json.dumps(obj).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = json.loads(self.rfile.read(n) or b"{}")
|
||||||
|
if self.path == "/users/sign_in.json":
|
||||||
|
app = body.get("user", {}).get("application", {})
|
||||||
|
if app.get("app_id") == "FGLair-eu-id" and \
|
||||||
|
app.get("app_secret") and \
|
||||||
|
body["user"]["password"] == "pw":
|
||||||
|
self._json(200, {"access_token": TOKEN})
|
||||||
|
else:
|
||||||
|
self._json(401, {"error": "invalid_credentials"})
|
||||||
|
else:
|
||||||
|
self._json(404, {})
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if not self.headers.get("Authorization") == f"auth_token {TOKEN}":
|
||||||
|
self._json(401, {"error": "auth_required"})
|
||||||
|
return
|
||||||
|
if self.path == "/apiv1/devices.json":
|
||||||
|
self._json(200, DEVICES)
|
||||||
|
return
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
if parsed.path.startswith("/apiv1/dsns/") and \
|
||||||
|
parsed.path.endswith("/lan.json"):
|
||||||
|
dsn = parsed.path.split("/")[3]
|
||||||
|
self._json(200, LANIPS.get(dsn, {"lanip": {}}))
|
||||||
|
return
|
||||||
|
self._json(404, {})
|
||||||
|
|
||||||
|
|
||||||
|
def run_discover(extra):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, DISCOVER, "--region", "eu",
|
||||||
|
"--email", "u@x.y", "--password", "pw"] + extra,
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscover(unittest.TestCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), MockCloud)
|
||||||
|
cls.port = cls.srv.server_address[1]
|
||||||
|
threading.Thread(target=cls.srv.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.srv.shutdown()
|
||||||
|
|
||||||
|
def _base(self, extra=None):
|
||||||
|
return ["--insecure", "--api-base", f"http://127.0.0.1:{self.port}"] \
|
||||||
|
+ (extra or [])
|
||||||
|
|
||||||
|
def test_json_full(self):
|
||||||
|
r = run_discover(self._base())
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
cfgs = [json.loads(l) for l in r.stdout.strip().splitlines()]
|
||||||
|
self.assertEqual(len(cfgs), 2)
|
||||||
|
c = cfgs[0]
|
||||||
|
self.assertEqual(c["dsn"], "AC000W00TEST0001")
|
||||||
|
self.assertEqual(c["lanip_key"], "KEY1abc==")
|
||||||
|
self.assertEqual(c["lanip_key_id"], 111)
|
||||||
|
self.assertEqual(c["model"], "AP-WC1E")
|
||||||
|
self.assertEqual(c["temp_type"], "C")
|
||||||
|
self.assertEqual(c["mac_address"], "a0c9a00d61c9")
|
||||||
|
|
||||||
|
def test_device_filter(self):
|
||||||
|
r = run_discover(self._base(["--device", "Bedroom"]))
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
cfgs = [json.loads(l) for l in r.stdout.strip().splitlines()]
|
||||||
|
self.assertEqual(len(cfgs), 1)
|
||||||
|
self.assertEqual(cfgs[0]["dsn"], "AC000W00TEST0002")
|
||||||
|
|
||||||
|
def test_esphome_secrets(self):
|
||||||
|
r = run_discover(self._base(["--device", "Living Room",
|
||||||
|
"--format", "esphome-secrets"]))
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
self.assertIn('livingroom_dsn: "AC000W00TEST0001"', r.stdout)
|
||||||
|
self.assertIn('livingroom_lanip_key: "KEY1abc=="', r.stdout)
|
||||||
|
self.assertIn("livingroom_lanip_key_id: 111", r.stdout)
|
||||||
|
|
||||||
|
def test_bad_password(self):
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, DISCOVER, "--region", "eu", "--email", "u@x.y",
|
||||||
|
"--password", "wrong", "--insecure",
|
||||||
|
"--api-base", f"http://127.0.0.1:{self.port}"],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertIn("Ошибка входа", r.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Executable
+219
@@ -0,0 +1,219 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""fglair-discover — облачный provisioning устройств FGLair (Ayla).
|
||||||
|
|
||||||
|
Получает lanip_key/lanip_key_id и параметры устройств из облака Ayla
|
||||||
|
(docs/PROTOCOL.md §7) и печатает конфиг для fglair-core. Ключ статичен
|
||||||
|
(зашит в модуль), облако используется только здесь.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
fglair-discover --region eu --email user@example.com [--password ...]
|
||||||
|
[--device "Living Room"] [--format json|esphome-secrets] [--out FILE]
|
||||||
|
|
||||||
|
Пароль: --password или env FGLAIR_PASSWORD (иначе getpass).
|
||||||
|
--api-base URL — внутренний флаг для тестов (мок облака).
|
||||||
|
|
||||||
|
Форматы:
|
||||||
|
json — конфиг в формате config_*.json (совместим с legacy и
|
||||||
|
fglctl)
|
||||||
|
esphome-secrets — блок для secrets.yaml (dsn/lanip_key/lanip_key_id)
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Секреты приложений FGLair (PROTOCOL.md §7, из APK FGLair 3.4.3).
|
||||||
|
REGIONS = {
|
||||||
|
"eu": {
|
||||||
|
"user_server": "user-field-eu.aylanetworks.com",
|
||||||
|
"device_server": "ads-eu.aylanetworks.com",
|
||||||
|
"app_id": "FGLair-eu-id",
|
||||||
|
"app_secret": "FGLair-eu-gpFbVBRoiJ8E3QWJ-QRULLL3j3U",
|
||||||
|
},
|
||||||
|
"us": {
|
||||||
|
"user_server": "user-field.aylanetworks.com",
|
||||||
|
"device_server": "ads-field.aylanetworks.com",
|
||||||
|
"app_id": "CJIOSP-id",
|
||||||
|
"app_secret": "CJIOSP-Vb8MQL_lFiYQ7DKjN0eCFXznKZE",
|
||||||
|
},
|
||||||
|
"cn": {
|
||||||
|
"user_server": "user-field.ayla.com.cn",
|
||||||
|
"device_server": "ads-field.ayla.com.cn",
|
||||||
|
"app_id": "FGLairField-cn-id",
|
||||||
|
"app_secret": "FGLairField-cn-zezg7Y60YpAvy3HPwxvWLnd4Oh4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
USER_AGENT = "fglair-discover/0.1 (fglair-core)"
|
||||||
|
|
||||||
|
|
||||||
|
def _http_json(method, url, payload=None, headers=None, insecure=False):
|
||||||
|
"""Запрос с JSON-ответом; возвращает (status, parsed)."""
|
||||||
|
data = json.dumps(payload).encode() if payload is not None else None
|
||||||
|
hdrs = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
hdrs["Content-Type"] = "application/json"
|
||||||
|
if headers:
|
||||||
|
hdrs.update(headers)
|
||||||
|
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
|
||||||
|
ctx = None
|
||||||
|
if insecure:
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode() or "null")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
body = json.loads(e.read().decode() or "null")
|
||||||
|
except Exception:
|
||||||
|
body = None
|
||||||
|
return e.code, body
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
sys.exit(f"Сеть недоступна ({url}): {e.reason}")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_in(args, region, build):
|
||||||
|
payload = {
|
||||||
|
"user": {
|
||||||
|
"email": args.email,
|
||||||
|
"password": args.password,
|
||||||
|
"application": {
|
||||||
|
"app_id": region["app_id"],
|
||||||
|
"app_secret": region["app_secret"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status, data = _http_json(
|
||||||
|
"POST", build(region["user_server"], "/users/sign_in.json"),
|
||||||
|
payload, insecure=args.insecure)
|
||||||
|
if status != 200 or not isinstance(data, dict):
|
||||||
|
detail = data.get("error") if isinstance(data, dict) else data
|
||||||
|
sys.exit(f"Ошибка входа ({status}): {detail}")
|
||||||
|
token = data.get("access_token")
|
||||||
|
if not token:
|
||||||
|
sys.exit(f"Вход без access_token (статус {status})")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def get_devices(args, region, token, build):
|
||||||
|
status, data = _http_json(
|
||||||
|
"GET", build(region["device_server"], "/apiv1/devices.json"),
|
||||||
|
headers={"Authorization": f"auth_token {token}"},
|
||||||
|
insecure=args.insecure)
|
||||||
|
if status != 200 or not isinstance(data, list):
|
||||||
|
sys.exit(f"Ошибка списка устройств ({status}): {data}")
|
||||||
|
devices = [d.get("device", {}) for d in data if isinstance(d, dict)]
|
||||||
|
if not all(d.get("dsn") for d in devices):
|
||||||
|
sys.exit("Облако вернуло устройство без dsn — неожиданный формат")
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def get_lanip(args, region, token, dsn, build):
|
||||||
|
status, data = _http_json(
|
||||||
|
"GET",
|
||||||
|
build(region["device_server"], f"/apiv1/dsns/{dsn}/lan.json"),
|
||||||
|
headers={"Authorization": f"auth_token {token}"},
|
||||||
|
insecure=args.insecure)
|
||||||
|
if status != 200 or not isinstance(data, dict):
|
||||||
|
sys.exit(f"Ошибка lan.json для {dsn} ({status}): {data}")
|
||||||
|
return data.get("lanip") or {}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Облачный discovery устройств FGLair (fglair-core)")
|
||||||
|
ap.add_argument("--region", choices=sorted(REGIONS), default="eu")
|
||||||
|
ap.add_argument("--email", default=os.environ.get("FGLAIR_EMAIL"))
|
||||||
|
ap.add_argument("--password", default=os.environ.get("FGLAIR_PASSWORD"))
|
||||||
|
ap.add_argument("--device", help="Фильтр по имени устройства (product_name)")
|
||||||
|
ap.add_argument("--format", choices=["json", "esphome-secrets"],
|
||||||
|
default="json")
|
||||||
|
ap.add_argument("--out", help="Записать в файл (иначе stdout)")
|
||||||
|
ap.add_argument("--insecure", action="store_true",
|
||||||
|
help="Не проверять TLS-сертификаты (самоподписанные прокси)")
|
||||||
|
ap.add_argument("--api-base",
|
||||||
|
help="ВНУТРЕННЕЕ: базовый URL мок-облака (тесты)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not args.email:
|
||||||
|
sys.exit("Укажите --email или FGLAIR_EMAIL")
|
||||||
|
if not args.password:
|
||||||
|
args.password = getpass.getpass("Пароль FGLair: ")
|
||||||
|
|
||||||
|
region = dict(REGIONS[args.region])
|
||||||
|
if args.api_base: # тестовый мок: оба сервера на одном хосте, plain http
|
||||||
|
host = args.api_base.removeprefix("http://").removeprefix("https://")
|
||||||
|
region["user_server"] = host
|
||||||
|
region["device_server"] = host
|
||||||
|
scheme = "http"
|
||||||
|
else:
|
||||||
|
scheme = "https"
|
||||||
|
|
||||||
|
def build(server, path):
|
||||||
|
return f"{scheme}://{server}{path}"
|
||||||
|
|
||||||
|
token = sign_in(args, region, build)
|
||||||
|
devices = get_devices(args, region, token, build)
|
||||||
|
if not devices:
|
||||||
|
sys.exit("У аккаунта нет устройств")
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
for dev in devices:
|
||||||
|
if args.device and dev.get("product_name") != args.device:
|
||||||
|
continue
|
||||||
|
dsn = dev["dsn"]
|
||||||
|
lanip = get_lanip(args, region, token, dsn, build)
|
||||||
|
lanip_key = lanip.get("lanip_key")
|
||||||
|
lanip_key_id = lanip.get("lanip_key_id")
|
||||||
|
if not lanip_key or lanip_key_id is None:
|
||||||
|
print(f"ВНИМАНИЕ: {dev.get('product_name')}: lan.json без ключа, "
|
||||||
|
"пропуск", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
outputs.append({
|
||||||
|
"name": dev.get("product_name", dsn),
|
||||||
|
"app": f"fglair-{args.region}",
|
||||||
|
"model": dev.get("oem_model", ""),
|
||||||
|
"dsn": dsn,
|
||||||
|
"temp_type": "C" if args.region == "eu" else "F",
|
||||||
|
"mac_address": (dev.get("mac") or "").replace(":", "").lower(),
|
||||||
|
"ip_address": dev.get("lan_ip", ""),
|
||||||
|
"lanip_key": lanip_key,
|
||||||
|
"lanip_key_id": lanip_key_id,
|
||||||
|
})
|
||||||
|
if not outputs:
|
||||||
|
sys.exit("Подходящих устройств не найдено")
|
||||||
|
|
||||||
|
if args.format == "esphome-secrets":
|
||||||
|
lines = []
|
||||||
|
for d in outputs:
|
||||||
|
slug = "".join(c for c in d["name"].lower() if c.isalnum()) or "ac"
|
||||||
|
lines.append(f"# {d['name']} ({d['model']})")
|
||||||
|
lines.append(f"{slug}_dsn: \"{d['dsn']}\"")
|
||||||
|
lines.append(f"{slug}_lanip_key: \"{d['lanip_key']}\"")
|
||||||
|
lines.append(f"{slug}_lanip_key_id: {d['lanip_key_id']}")
|
||||||
|
lines.append("")
|
||||||
|
text = "\n".join(lines)
|
||||||
|
else:
|
||||||
|
# По одной компактной строке на устройство (парсится построчно;
|
||||||
|
# каждая строка — самостоятельный конфиг config_*.json).
|
||||||
|
text = "\n".join(json.dumps(d, ensure_ascii=False)
|
||||||
|
for d in outputs) + "\n"
|
||||||
|
|
||||||
|
if args.out:
|
||||||
|
# Ключ устройства — секрет: файл только для владельца.
|
||||||
|
fd = os.open(args.out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
print(f"Записано: {args.out} (права 0600)", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
sys.stdout.write(text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user