Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31cd56f082 | ||
|
|
fa1718f405 | ||
|
|
17bea87fdf | ||
|
|
fe3bda646f | ||
|
|
fd5f125d04 | ||
|
|
e6f6e2d5f4 | ||
|
|
301cf5cf70 | ||
|
|
9469f0104c | ||
|
|
359203abfe |
@@ -8,6 +8,7 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
|
|
||||||
# Локальный конфиг устройства (содержит lanip_key)
|
# Локальный конфиг устройства (содержит lanip_key)
|
||||||
|
config_kata.json
|
||||||
docs/legacy/config_kata.json
|
docs/legacy/config_kata.json
|
||||||
|
|
||||||
# Сборка
|
# Сборка
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ if(ESP_PLATFORM)
|
|||||||
"src/ayla/httpd.cpp"
|
"src/ayla/httpd.cpp"
|
||||||
"src/ayla/log.cpp"
|
"src/ayla/log.cpp"
|
||||||
"src/ayla/session.cpp"
|
"src/ayla/session.cpp"
|
||||||
|
"src/aircon/c_api.cpp"
|
||||||
|
"src/aircon/session.cpp"
|
||||||
|
"src/aircon/tables.cpp"
|
||||||
"src/ayla/platform/esp-idf/platform.cpp"
|
"src/ayla/platform/esp-idf/platform.cpp"
|
||||||
INCLUDE_DIRS
|
INCLUDE_DIRS
|
||||||
"include"
|
"include"
|
||||||
@@ -40,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 ----
|
||||||
@@ -81,6 +85,9 @@ else()
|
|||||||
src/ayla/httpd.cpp
|
src/ayla/httpd.cpp
|
||||||
src/ayla/log.cpp
|
src/ayla/log.cpp
|
||||||
src/ayla/session.cpp
|
src/ayla/session.cpp
|
||||||
|
src/aircon/c_api.cpp
|
||||||
|
src/aircon/session.cpp
|
||||||
|
src/aircon/tables.cpp
|
||||||
src/ayla/platform/posix/platform.cpp
|
src/ayla/platform/posix/platform.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(fgl-aircon
|
target_include_directories(fgl-aircon
|
||||||
@@ -102,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.
|
||||||
@@ -296,9 +296,9 @@ HA-превью (PLAN_HOME_ASSISTANT §4) и тестами.
|
|||||||
| M0 ✅ | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 |
|
| M0 ✅ | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 |
|
||||||
| 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. |
|
||||||
|
|
||||||
## Краткая выжимка протокола
|
## Краткая выжимка протокола
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ python /opt/aircon/main.py \
|
|||||||
run \
|
run \
|
||||||
--port 3355 \
|
--port 3355 \
|
||||||
--mqtt_host 192.168.88.1 \
|
--mqtt_host 192.168.88.1 \
|
||||||
--mqtt_user "aircon:ic6ahrahbaijaVe3eTip" \
|
--mqtt_user "${MQTT_USER:-user:password}" \
|
||||||
--local_ip 192.168.88.1 \
|
--local_ip 192.168.88.1 \
|
||||||
--config /opt/aircon/config_kata.json \
|
--config /opt/aircon/config_kata.json \
|
||||||
"$@"
|
"$@"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// C-API (extern "C") fgl-aircon — для cffi/pyfglair (Home Assistant).
|
||||||
|
// C++-пользователям — fgl-aircon/*.hpp.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Версия библиотеки.
|
||||||
|
const char* fgl_version(void);
|
||||||
|
|
||||||
|
// Логирование.
|
||||||
|
typedef void (*fgl_log_sink_fn)(int level, const char* msg, unsigned len,
|
||||||
|
void* ctx);
|
||||||
|
void fgl_log_set_sink(fgl_log_sink_fn sink, void* ctx);
|
||||||
|
void fgl_log_set_level(int min_level);
|
||||||
|
|
||||||
|
// Состояния/ошибки (значения совпадают с fgl::aircon::State/Error).
|
||||||
|
typedef enum {
|
||||||
|
FGL_STATE_IDLE = 0,
|
||||||
|
FGL_STATE_REGISTERING = 1,
|
||||||
|
FGL_STATE_ONLINE = 2,
|
||||||
|
FGL_STATE_RECOVERING = 3,
|
||||||
|
FGL_STATE_OFFLINE = 4,
|
||||||
|
FGL_STATE_KEY_ERROR = 5,
|
||||||
|
} fgl_state_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
FGL_ERR_NONE = 0,
|
||||||
|
FGL_ERR_NO_SLOT = 1,
|
||||||
|
FGL_ERR_UNREACHABLE = 2,
|
||||||
|
FGL_ERR_KEY_MISMATCH = 3,
|
||||||
|
FGL_ERR_BAD_KEY_EXCHANGE = 4,
|
||||||
|
FGL_ERR_ACTIVATION_TIMEOUT = 5,
|
||||||
|
FGL_ERR_DECRYPT_FAILED = 6,
|
||||||
|
} fgl_error_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
FGL_PROP_OPERATION_MODE = 0,
|
||||||
|
FGL_PROP_FAN_SPEED = 1,
|
||||||
|
FGL_PROP_ADJUST_TEMPERATURE = 2,
|
||||||
|
FGL_PROP_DISPLAY_TEMPERATURE = 3,
|
||||||
|
FGL_PROP_AF_VERTICAL_DIRECTION = 4,
|
||||||
|
FGL_PROP_AF_VERTICAL_SWING = 5,
|
||||||
|
FGL_PROP_AF_HORIZONTAL_DIRECTION = 6,
|
||||||
|
FGL_PROP_AF_HORIZONTAL_SWING = 7,
|
||||||
|
FGL_PROP_AF_VERTICAL_MOVE_STEP1 = 8,
|
||||||
|
FGL_PROP_AF_HORIZONTAL_MOVE_STEP1 = 9,
|
||||||
|
FGL_PROP_OUTDOOR_LOW_NOISE = 10,
|
||||||
|
FGL_PROP_INDOOR_FAN_CONTROL = 11,
|
||||||
|
FGL_PROP_HUMAN_DET_AUTO_SAVE = 12,
|
||||||
|
FGL_PROP_MIN_HEAT = 13,
|
||||||
|
FGL_PROP_POWERFUL_MODE = 14,
|
||||||
|
FGL_PROP_COIL_DRY_MODE = 15,
|
||||||
|
FGL_PROP_ECONOMY_MODE = 16,
|
||||||
|
FGL_PROP_MASTER_TIMER_ON_OFF_1 = 17,
|
||||||
|
FGL_PROP_MASTER_TIMER_ON_OFF_2 = 18,
|
||||||
|
FGL_PROP_ERROR_CODE = 19,
|
||||||
|
FGL_PROP_DEMAND_CONTROL = 20,
|
||||||
|
FGL_PROP_FILTER_SIGN_RESET_DISPLAY = 21,
|
||||||
|
FGL_PROP_FILTER_SIGN_RESET = 22,
|
||||||
|
FGL_PROP_OP_STATUS = 23,
|
||||||
|
FGL_PROP_DEVICE_NAME = 24,
|
||||||
|
FGL_PROP_BUILDING_NAME = 25,
|
||||||
|
FGL_PROP_WIFI_LED_ENABLE = 26,
|
||||||
|
FGL_PROP_SERVICE_CONTACT_NAME = 27,
|
||||||
|
FGL_PROP_SERVICE_CONTACT_PHONE = 28,
|
||||||
|
FGL_PROP_SERVICE_CONTACT_EMAIL = 29,
|
||||||
|
FGL_PROP_AF_HORIZONTAL_NUM_DIR = 30,
|
||||||
|
FGL_PROP_AF_VERTICAL_NUM_DIR = 31,
|
||||||
|
FGL_PROP_DEVICE_CAPABILITIES = 32,
|
||||||
|
FGL_PROP_GET_PROP = 33,
|
||||||
|
FGL_PROP_HUMAN_DET = 34,
|
||||||
|
FGL_PROP_MONITOR1 = 35,
|
||||||
|
FGL_PROP_REFRESH = 36,
|
||||||
|
FGL_PROP_COUNT = 37,
|
||||||
|
} fgl_prop_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
FGL_VALUE_INT = 0,
|
||||||
|
FGL_VALUE_BOOL = 1,
|
||||||
|
FGL_VALUE_STRING = 2,
|
||||||
|
} fgl_value_kind_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
fgl_value_kind_t kind;
|
||||||
|
int64_t i;
|
||||||
|
char s[64];
|
||||||
|
} fgl_value_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
FGL_TEMPLATE_A = 0,
|
||||||
|
FGL_TEMPLATE_B = 1,
|
||||||
|
FGL_TEMPLATE_F = 2,
|
||||||
|
} fgl_template_t;
|
||||||
|
|
||||||
|
// Конфигурация. Строки копируются при создании.
|
||||||
|
typedef struct {
|
||||||
|
const char* host;
|
||||||
|
uint16_t device_port;
|
||||||
|
const char* dsn;
|
||||||
|
const char* lanip_key;
|
||||||
|
uint32_t lanip_key_id;
|
||||||
|
fgl_template_t tmpl;
|
||||||
|
uint16_t listen_port;
|
||||||
|
uint32_t keepalive_ms;
|
||||||
|
uint8_t max_queue;
|
||||||
|
} fgl_config_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
fgl_prop_t prop;
|
||||||
|
int32_t cmd_id; // -1 — спонтанное обновление
|
||||||
|
int32_t status;
|
||||||
|
fgl_value_t value;
|
||||||
|
} fgl_property_event_t;
|
||||||
|
|
||||||
|
typedef void (*fgl_on_state_fn)(void* ctx, fgl_state_t st, fgl_error_t err);
|
||||||
|
typedef void (*fgl_on_property_fn)(void* ctx,
|
||||||
|
const fgl_property_event_t* ev);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
fgl_on_state_fn on_state;
|
||||||
|
fgl_on_property_fn on_property;
|
||||||
|
void* ctx;
|
||||||
|
} fgl_callbacks_t;
|
||||||
|
|
||||||
|
// Непрозрачная сессия (определение — в реализации).
|
||||||
|
typedef struct fgl_session fgl_session_t;
|
||||||
|
|
||||||
|
fgl_session_t* fgl_session_create(const fgl_config_t* cfg,
|
||||||
|
const fgl_callbacks_t* cbs);
|
||||||
|
void fgl_session_destroy(fgl_session_t* s);
|
||||||
|
int fgl_session_start(fgl_session_t* s); // 1 ok / 0 ошибка
|
||||||
|
void fgl_session_stop(fgl_session_t* s);
|
||||||
|
fgl_state_t fgl_session_state(const fgl_session_t* s);
|
||||||
|
fgl_error_t fgl_session_last_error(const fgl_session_t* s);
|
||||||
|
|
||||||
|
int fgl_session_set_int(fgl_session_t* s, fgl_prop_t prop, int64_t value);
|
||||||
|
int fgl_session_set_bool(fgl_session_t* s, fgl_prop_t prop, int value);
|
||||||
|
int fgl_session_set_string(fgl_session_t* s, fgl_prop_t prop,
|
||||||
|
const char* value);
|
||||||
|
int fgl_session_get_prop(fgl_session_t* s, fgl_prop_t prop);
|
||||||
|
|
||||||
|
int fgl_session_batch_begin(fgl_session_t* s);
|
||||||
|
int fgl_session_batch_commit(fgl_session_t* s);
|
||||||
|
int fgl_session_batch_abort(fgl_session_t* s);
|
||||||
|
|
||||||
|
int fgl_session_cached(const fgl_session_t* s, fgl_prop_t prop,
|
||||||
|
fgl_value_t* out);
|
||||||
|
|
||||||
|
// Интроспекция шаблонов (превью HA, CLI).
|
||||||
|
int fgl_template_detect(const char* oem_model); // -1 — неизвестно
|
||||||
|
const char* fgl_prop_name(fgl_template_t t, fgl_prop_t prop); // NULL — нет
|
||||||
|
const char* fgl_prop_base_type(fgl_template_t t, fgl_prop_t prop);
|
||||||
|
int fgl_prop_read_only(fgl_template_t t, fgl_prop_t prop); // -1 — нет
|
||||||
|
int64_t fgl_convert_to_display(fgl_template_t t, fgl_prop_t prop,
|
||||||
|
int64_t raw);
|
||||||
|
int64_t fgl_convert_from_input(fgl_template_t t, fgl_prop_t prop,
|
||||||
|
int64_t api_value);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} // extern "C"
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Публичная сессия fgl-aircon: обёртка над ayla-сессией с таблицами свойств
|
||||||
|
// шаблонов, конверсиями и кэшем значений.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "fgl-aircon/templates.hpp"
|
||||||
|
#include "fgl-aircon/types.hpp"
|
||||||
|
|
||||||
|
namespace fgl::aircon {
|
||||||
|
|
||||||
|
// Глобальный приёмник логов ядра (nullptr — отключено). Быстрый, реентерабельный.
|
||||||
|
// КОНТРАКТ: вызывать ДО create() сессий; не менять при живых сессиях.
|
||||||
|
using LogSink = void (*)(int level, const char* msg, size_t len, void* ctx);
|
||||||
|
void set_log_sink(LogSink sink, void* ctx);
|
||||||
|
void set_log_level(int min_level); // 0=debug..3=error, default 1
|
||||||
|
|
||||||
|
class Session {
|
||||||
|
public:
|
||||||
|
// Создаёт сессию (одноразовые аллокации). nullptr при некорректном конфиге.
|
||||||
|
static Session* create(const Config& cfg, const Callbacks& cbs);
|
||||||
|
~Session();
|
||||||
|
|
||||||
|
Session(const Session&) = delete;
|
||||||
|
Session& operator=(const Session&) = delete;
|
||||||
|
|
||||||
|
// Запуск (httpd + сессионный поток). false — ошибка bind/listen.
|
||||||
|
bool start();
|
||||||
|
// Штатное завершение: DELETE-команда + notify, ожидание выдачи ≤2с.
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
State state() const;
|
||||||
|
Error last_error() const;
|
||||||
|
|
||||||
|
// ---- Управление свойствами (потокобезопасны; очередь с coalescing) ----
|
||||||
|
// set_int/set_bool: read-only свойства и свойства не из шаблона → false.
|
||||||
|
// Значение клампится по диапазону (override), конверсия from_input.
|
||||||
|
bool set_int(Prop prop, int64_t value);
|
||||||
|
bool set_bool(Prop prop, bool value);
|
||||||
|
bool set_string(Prop prop, const char* value); // только строковые
|
||||||
|
|
||||||
|
// Запросить обновление (ответ придёт on_property с cmd_id).
|
||||||
|
bool get_prop(Prop prop);
|
||||||
|
|
||||||
|
// Пакет команд: один local_reg notify=1 на commit.
|
||||||
|
bool batch_begin();
|
||||||
|
bool batch_commit();
|
||||||
|
bool batch_abort();
|
||||||
|
|
||||||
|
// Кэш значений (обновляется push'ами; set_* — оптимистично).
|
||||||
|
bool cached(Prop prop, Value* out) const;
|
||||||
|
|
||||||
|
// Телеметрия.
|
||||||
|
uint32_t rekey_count() const;
|
||||||
|
uint32_t pushes_ok() const;
|
||||||
|
uint32_t pushes_bad() const;
|
||||||
|
uint32_t commands_served() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
Session();
|
||||||
|
struct Impl;
|
||||||
|
Impl* impl_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace fgl::aircon
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Интроспекция шаблонов и конверсии (для HA-превью, ESPHome, CLI, тестов).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "fgl-aircon/types.hpp"
|
||||||
|
|
||||||
|
namespace fgl::aircon {
|
||||||
|
|
||||||
|
// Описание свойства в шаблоне.
|
||||||
|
struct PropInfo {
|
||||||
|
Prop prop;
|
||||||
|
const char* name; // имя в протоколе ("operation_mode")
|
||||||
|
const char* base_type; // "integer" | "boolean" | "string"
|
||||||
|
bool read_only;
|
||||||
|
ValueKind kind;
|
||||||
|
int64_t raw_min; // диапазон raw (справочно, из таблиц приложения)
|
||||||
|
int64_t raw_max;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Список свойств шаблона (constexpr-таблица; kCount-терминатора нет —
|
||||||
|
// используйте prop_info_count).
|
||||||
|
const PropInfo* prop_info_begin(Template t);
|
||||||
|
size_t prop_info_count(Template t);
|
||||||
|
|
||||||
|
// Поиск по enum (в шаблоне). nullptr — свойство не входит в шаблон.
|
||||||
|
const PropInfo* prop_info_find(Template t, Prop prop);
|
||||||
|
|
||||||
|
// Поиск по имени протокола (в шаблоне). nullptr — не найдено.
|
||||||
|
const PropInfo* prop_info_find_by_name(Template t, const char* name);
|
||||||
|
|
||||||
|
// Имя enum-значения свойства ("OperationMode") — для логов/CLI.
|
||||||
|
const char* prop_enum_name(Prop prop);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Конверсии. По умолчанию (kTemplateDefault):
|
||||||
|
// - почти все свойства — тождественны (API-значение == raw);
|
||||||
|
// - DisplayTemperature: raw 0.01°C со смещением 5000 → API 0.1°C:
|
||||||
|
// api = (raw - 5000) / 10 [округление к нулю; raw=7300 → 230 → 23.0°C]
|
||||||
|
// Override (linear/custom) применяется поверх (план §4).
|
||||||
|
// КОНТРАКТ Linear (при прямом вызове без Session::create): num != 0 и
|
||||||
|
// den != 0 — Session::create это валидирует; самодельные списки override'ов
|
||||||
|
// для прямых вызовов конверсий должны удовлетворять тому же.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
int64_t convert_to_display(Template t, Prop prop, int64_t raw,
|
||||||
|
const PropOverride* overrides);
|
||||||
|
// Обратная конверсия (для set_*). kLinear в from_input — ИНВЕРСИЯ той же
|
||||||
|
// спецификации: raw = (api - offset) * den / num (задавайте те же
|
||||||
|
// коэффициенты, что и в to_display).
|
||||||
|
int64_t convert_from_input(Template t, Prop prop, int64_t api_value,
|
||||||
|
const PropOverride* overrides);
|
||||||
|
|
||||||
|
// Диапазон API-значений с учётом override (false — диапазона нет).
|
||||||
|
bool prop_api_range(Template t, Prop prop, const PropOverride* overrides,
|
||||||
|
int64_t* min, int64_t* max);
|
||||||
|
|
||||||
|
} // namespace fgl::aircon
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Публичные типы fgl-aircon (уровень aircon: свойства, конверсии, конфиг).
|
||||||
|
// Протокольные детали скрыты в src/ayla; этот заголовок — стабильный API
|
||||||
|
// для интеграций (ESPHome, HA/pyfglair, CLI).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace fgl::aircon {
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Состояние сессии (зеркалирует ayla-слой).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
enum class State : uint8_t {
|
||||||
|
kIdle = 0, // создан, не запущен
|
||||||
|
kRegistering, // local_reg отправлен, ждём key exchange
|
||||||
|
kOnline, // сессия активна
|
||||||
|
kRecovering, // самолечение (тишина → re-key)
|
||||||
|
kOffline, // модуль недоступен (backoff) или нет слотов
|
||||||
|
kKeyError, // lanip_key_id не совпал — смена конфига вручную
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class Error : int {
|
||||||
|
kNone = 0,
|
||||||
|
kNoSlot = 1,
|
||||||
|
kUnreachable = 2,
|
||||||
|
kKeyMismatch = 3,
|
||||||
|
kBadKeyExchange = 4,
|
||||||
|
kActivationTimeout = 5,
|
||||||
|
kDecryptFailed = 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Свойства (шаблоны A/B/F; PROTOCOL.md §8.2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
enum class Prop : uint8_t {
|
||||||
|
OperationMode = 0,
|
||||||
|
FanSpeed,
|
||||||
|
AdjustTemperature,
|
||||||
|
DisplayTemperature,
|
||||||
|
AfVerticalDirection,
|
||||||
|
AfVerticalSwing,
|
||||||
|
AfHorizontalDirection,
|
||||||
|
AfHorizontalSwing,
|
||||||
|
AfVerticalMoveStep1,
|
||||||
|
AfHorizontalMoveStep1,
|
||||||
|
OutdoorLowNoise,
|
||||||
|
IndoorFanControl,
|
||||||
|
HumanDetAutoSave,
|
||||||
|
MinHeat,
|
||||||
|
PowerfulMode,
|
||||||
|
CoilDryMode,
|
||||||
|
EconomyMode,
|
||||||
|
MasterTimerOnOff1,
|
||||||
|
MasterTimerOnOff2,
|
||||||
|
ErrorCode,
|
||||||
|
DemandControl,
|
||||||
|
FilterSignResetDisplay,
|
||||||
|
FilterSignReset,
|
||||||
|
OpStatus,
|
||||||
|
DeviceName,
|
||||||
|
BuildingName,
|
||||||
|
WifiLedEnable,
|
||||||
|
ServiceContactName,
|
||||||
|
ServiceContactPhone,
|
||||||
|
ServiceContactEmail,
|
||||||
|
AfHorizontalNumDir,
|
||||||
|
AfVerticalNumDir,
|
||||||
|
DeviceCapabilities,
|
||||||
|
GetProp,
|
||||||
|
HumanDet,
|
||||||
|
Monitor1,
|
||||||
|
Refresh,
|
||||||
|
kCount
|
||||||
|
};
|
||||||
|
|
||||||
|
// Тип значения свойства.
|
||||||
|
enum class ValueKind : uint8_t { kInt = 0, kBool, kString };
|
||||||
|
|
||||||
|
struct Value {
|
||||||
|
ValueKind kind = ValueKind::kInt;
|
||||||
|
int64_t i = 0; // kInt / kBool (0|1)
|
||||||
|
char s[64] = {}; // kString
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Шаблоны устройств (oem_model → шаблон, PROTOCOL.md §8.1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
enum class Template : uint8_t { kA = 0, kB, kF };
|
||||||
|
|
||||||
|
// Определение шаблона по oem_model ("AP-WC1E" → kA). Неизвестные модели
|
||||||
|
// трактуются как kA (как и legacy) — различить можно template_is_known.
|
||||||
|
Template template_detect(const char* oem_model);
|
||||||
|
// Известна ли модель (false → template_detect вернёт kA как fallback).
|
||||||
|
bool template_is_known(const char* oem_model);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Конверсии (PROTOCOL.md §8.3; требования плана §4)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
enum class ConvKind : uint8_t {
|
||||||
|
kTemplateDefault = 0, // из таблицы шаблона
|
||||||
|
kLinear, // disp = raw*num/den + offset (int64, округление к нулю)
|
||||||
|
kCustomFn, // disp = fn(raw, ctx)
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Linear {
|
||||||
|
int64_t num = 1;
|
||||||
|
int64_t den = 1;
|
||||||
|
int64_t offset = 0;
|
||||||
|
// to_display: api = raw*num/den + offset
|
||||||
|
// from_input: raw = (api - offset)*den/num (инверсия)
|
||||||
|
};
|
||||||
|
|
||||||
|
using ConvFn = int64_t (*)(int64_t raw, void* ctx);
|
||||||
|
|
||||||
|
struct Conversion {
|
||||||
|
ConvKind kind = ConvKind::kTemplateDefault;
|
||||||
|
Linear linear{}; // при kLinear
|
||||||
|
ConvFn fn = nullptr; // при kCustomFn
|
||||||
|
void* ctx = nullptr; // контекст kCustomFn
|
||||||
|
};
|
||||||
|
|
||||||
|
// Точечная настройка одного свойства (nullptr-terminnated массив в конфиге).
|
||||||
|
struct PropOverride {
|
||||||
|
Prop prop;
|
||||||
|
bool has_range = false;
|
||||||
|
int64_t min = 0; // в API-единицах (после конверсии)
|
||||||
|
int64_t max = 0;
|
||||||
|
Conversion to_display; // raw -> API
|
||||||
|
Conversion from_input; // API -> raw
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Конфигурация и колбэки сессии
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
struct Config {
|
||||||
|
// host/dsn/lanip_key копируются при create; overrides — нет (см. ниже).
|
||||||
|
const char* host = nullptr; // DNS-имя или IP модуля
|
||||||
|
uint16_t device_port = 80;
|
||||||
|
const char* dsn = nullptr; // "AC000W00XXXXXXX"
|
||||||
|
const char* lanip_key = nullptr; // base64-строка как есть
|
||||||
|
uint32_t lanip_key_id = 0;
|
||||||
|
Template tmpl = Template::kA;
|
||||||
|
uint16_t listen_port = 10275;
|
||||||
|
uint32_t keepalive_ms = 15000;
|
||||||
|
uint8_t max_queue = 40; // >= полного батча шаблона (33)
|
||||||
|
// Переопределения конверсий. МАССИВ ДОЛЖЕН ПЕРЕЖИТЬ СЕССИЮ (не копируется;
|
||||||
|
// читается при каждом set/push). Терминатор: элемент с prop == kCount.
|
||||||
|
const PropOverride* overrides = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PropertyEvent {
|
||||||
|
Prop prop;
|
||||||
|
Value value; // уже сконвертированное (to_display)
|
||||||
|
int cmd_id = -1; // ответ на GET; -1 — спонтанное обновление
|
||||||
|
int status = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Callbacks {
|
||||||
|
// КОНТРАКТ: вызываются из потоков ядра; быстрые и реентерабельные;
|
||||||
|
// вызывать stop() из колбэка запрещено.
|
||||||
|
void (*on_state)(void* ctx, State st, Error err);
|
||||||
|
void (*on_property)(void* ctx, const PropertyEvent& ev);
|
||||||
|
void* ctx = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace fgl::aircon
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
// Реализация extern "C" шейма (cffi/pyfglair). Значения enum'ов C-API
|
||||||
|
// обязаны совпадать с C++ enum'ами — проверено static_assert'ами.
|
||||||
|
#include "fgl-aircon/c_api.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <new>
|
||||||
|
|
||||||
|
#include "fgl-aircon/session.hpp"
|
||||||
|
#include "fgl-aircon/templates.hpp"
|
||||||
|
#include "fgl-aircon/version.hpp"
|
||||||
|
|
||||||
|
using fgl::aircon::Callbacks;
|
||||||
|
using fgl::aircon::Config;
|
||||||
|
using fgl::aircon::Error;
|
||||||
|
using fgl::aircon::Prop;
|
||||||
|
using fgl::aircon::PropInfo;
|
||||||
|
using fgl::aircon::State;
|
||||||
|
using fgl::aircon::Template;
|
||||||
|
using fgl::aircon::Value;
|
||||||
|
|
||||||
|
static_assert(FGL_STATE_IDLE == static_cast<int>(State::kIdle));
|
||||||
|
static_assert(FGL_STATE_REGISTERING == static_cast<int>(State::kRegistering));
|
||||||
|
static_assert(FGL_STATE_ONLINE == static_cast<int>(State::kOnline));
|
||||||
|
static_assert(FGL_STATE_RECOVERING == static_cast<int>(State::kRecovering));
|
||||||
|
static_assert(FGL_STATE_OFFLINE == static_cast<int>(State::kOffline));
|
||||||
|
static_assert(FGL_STATE_KEY_ERROR == static_cast<int>(State::kKeyError));
|
||||||
|
static_assert(FGL_ERR_NONE == static_cast<int>(Error::kNone));
|
||||||
|
static_assert(FGL_ERR_NO_SLOT == static_cast<int>(Error::kNoSlot));
|
||||||
|
static_assert(FGL_ERR_UNREACHABLE == static_cast<int>(Error::kUnreachable));
|
||||||
|
static_assert(FGL_ERR_KEY_MISMATCH == static_cast<int>(Error::kKeyMismatch));
|
||||||
|
static_assert(FGL_ERR_BAD_KEY_EXCHANGE == static_cast<int>(Error::kBadKeyExchange));
|
||||||
|
static_assert(FGL_ERR_ACTIVATION_TIMEOUT == static_cast<int>(Error::kActivationTimeout));
|
||||||
|
static_assert(FGL_ERR_DECRYPT_FAILED == static_cast<int>(Error::kDecryptFailed));
|
||||||
|
static_assert(FGL_PROP_OPERATION_MODE == static_cast<int>(Prop::OperationMode));
|
||||||
|
static_assert(FGL_PROP_FAN_SPEED == static_cast<int>(Prop::FanSpeed));
|
||||||
|
static_assert(FGL_PROP_ADJUST_TEMPERATURE == static_cast<int>(Prop::AdjustTemperature));
|
||||||
|
static_assert(FGL_PROP_DISPLAY_TEMPERATURE == static_cast<int>(Prop::DisplayTemperature));
|
||||||
|
static_assert(FGL_PROP_ECONOMY_MODE == static_cast<int>(Prop::EconomyMode));
|
||||||
|
static_assert(FGL_PROP_ERROR_CODE == static_cast<int>(Prop::ErrorCode));
|
||||||
|
static_assert(FGL_PROP_DEVICE_CAPABILITIES == static_cast<int>(Prop::DeviceCapabilities));
|
||||||
|
static_assert(FGL_PROP_REFRESH == static_cast<int>(Prop::Refresh));
|
||||||
|
static_assert(FGL_PROP_COUNT == static_cast<int>(Prop::kCount));
|
||||||
|
static_assert(FGL_TEMPLATE_A == static_cast<int>(Template::kA));
|
||||||
|
static_assert(FGL_TEMPLATE_B == static_cast<int>(Template::kB));
|
||||||
|
static_assert(FGL_TEMPLATE_F == static_cast<int>(Template::kF));
|
||||||
|
static_assert(FGL_VALUE_INT == static_cast<int>(fgl::aircon::ValueKind::kInt));
|
||||||
|
static_assert(FGL_VALUE_BOOL == static_cast<int>(fgl::aircon::ValueKind::kBool));
|
||||||
|
static_assert(FGL_VALUE_STRING ==
|
||||||
|
static_cast<int>(fgl::aircon::ValueKind::kString));
|
||||||
|
|
||||||
|
const char* fgl_version(void) { return FGL_AIRCON_VERSION_STRING; }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Логирование
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
namespace {
|
||||||
|
struct LogWrap {
|
||||||
|
fgl_log_sink_fn fn;
|
||||||
|
void* ctx;
|
||||||
|
};
|
||||||
|
LogWrap g_log_wrap;
|
||||||
|
|
||||||
|
void log_wrap_trampoline(int level, const char* msg, size_t len, void*) {
|
||||||
|
if (g_log_wrap.fn != nullptr) {
|
||||||
|
g_log_wrap.fn(level, msg, len, g_log_wrap.ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void fgl_log_set_sink(fgl_log_sink_fn sink, void* ctx) {
|
||||||
|
g_log_wrap.fn = sink;
|
||||||
|
g_log_wrap.ctx = ctx;
|
||||||
|
fgl::aircon::set_log_sink(sink != nullptr ? log_wrap_trampoline : nullptr,
|
||||||
|
nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fgl_log_set_level(int min_level) {
|
||||||
|
fgl::aircon::set_log_level(min_level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Сессия
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Контексты колбэков, хранимые в fgl_session.
|
||||||
|
struct CbCtx {
|
||||||
|
fgl_on_state_fn on_state;
|
||||||
|
fgl_on_property_fn on_property;
|
||||||
|
void* user;
|
||||||
|
};
|
||||||
|
|
||||||
|
void on_state_trampoline(void* ctx, State st, Error err) {
|
||||||
|
auto* c = static_cast<CbCtx*>(ctx);
|
||||||
|
if (c->on_state != nullptr) {
|
||||||
|
c->on_state(c->user, static_cast<fgl_state_t>(st),
|
||||||
|
static_cast<fgl_error_t>(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_property_trampoline(void* ctx,
|
||||||
|
const fgl::aircon::PropertyEvent& ev) {
|
||||||
|
auto* c = static_cast<CbCtx*>(ctx);
|
||||||
|
if (c->on_property == nullptr) return;
|
||||||
|
fgl_property_event_t out{};
|
||||||
|
out.prop = static_cast<fgl_prop_t>(ev.prop);
|
||||||
|
out.cmd_id = ev.cmd_id;
|
||||||
|
out.status = ev.status;
|
||||||
|
out.value.kind = static_cast<fgl_value_kind_t>(ev.value.kind);
|
||||||
|
out.value.i = ev.value.i;
|
||||||
|
memcpy(out.value.s, ev.value.s, sizeof(out.value.s));
|
||||||
|
c->on_property(c->user, &out);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
struct fgl_session {
|
||||||
|
fgl::aircon::Session* s;
|
||||||
|
CbCtx* cbctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
fgl_session_t* fgl_session_create(const fgl_config_t* cfg,
|
||||||
|
const fgl_callbacks_t* cbs) {
|
||||||
|
if (cfg == nullptr) return nullptr;
|
||||||
|
Config cc{};
|
||||||
|
cc.host = cfg->host;
|
||||||
|
cc.device_port = cfg->device_port;
|
||||||
|
cc.dsn = cfg->dsn;
|
||||||
|
cc.lanip_key = cfg->lanip_key;
|
||||||
|
cc.lanip_key_id = cfg->lanip_key_id;
|
||||||
|
cc.tmpl = static_cast<Template>(cfg->tmpl);
|
||||||
|
cc.listen_port = cfg->listen_port;
|
||||||
|
cc.keepalive_ms = cfg->keepalive_ms;
|
||||||
|
cc.max_queue = cfg->max_queue;
|
||||||
|
// Конверсии-override через C-API не задаются (применяются на стороне
|
||||||
|
// pyfglair при превью/вычислениях; сессия работает в единицах API).
|
||||||
|
|
||||||
|
auto* cbctx = new (std::nothrow) CbCtx{};
|
||||||
|
if (cbctx == nullptr) return nullptr;
|
||||||
|
cbctx->on_state = cbs != nullptr ? cbs->on_state : nullptr;
|
||||||
|
cbctx->on_property = cbs != nullptr ? cbs->on_property : nullptr;
|
||||||
|
cbctx->user = cbs != nullptr ? cbs->ctx : nullptr;
|
||||||
|
|
||||||
|
Callbacks cpp_cbs{};
|
||||||
|
cpp_cbs.on_state = on_state_trampoline;
|
||||||
|
cpp_cbs.on_property = on_property_trampoline;
|
||||||
|
cpp_cbs.ctx = cbctx;
|
||||||
|
|
||||||
|
auto* s = fgl::aircon::Session::create(cc, cpp_cbs);
|
||||||
|
if (s == nullptr) {
|
||||||
|
delete cbctx;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
auto* out = new (std::nothrow) fgl_session_t();
|
||||||
|
if (out == nullptr) {
|
||||||
|
delete s;
|
||||||
|
delete cbctx;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
out->s = s;
|
||||||
|
out->cbctx = cbctx;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fgl_session_destroy(fgl_session_t* s) {
|
||||||
|
if (s == nullptr) return;
|
||||||
|
delete s->s;
|
||||||
|
delete s->cbctx;
|
||||||
|
delete s;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fgl_session_start(fgl_session_t* s) {
|
||||||
|
return (s != nullptr && s->s->start()) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fgl_session_stop(fgl_session_t* s) {
|
||||||
|
if (s != nullptr) s->s->stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
fgl_state_t fgl_session_state(const fgl_session_t* s) {
|
||||||
|
return s != nullptr ? static_cast<fgl_state_t>(s->s->state())
|
||||||
|
: FGL_STATE_IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
fgl_error_t fgl_session_last_error(const fgl_session_t* s) {
|
||||||
|
return s != nullptr ? static_cast<fgl_error_t>(s->s->last_error())
|
||||||
|
: FGL_ERR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fgl_session_set_int(fgl_session_t* s, fgl_prop_t prop, int64_t value) {
|
||||||
|
return (s != nullptr && s->s->set_int(static_cast<Prop>(prop), value)) ? 1
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fgl_session_set_bool(fgl_session_t* s, fgl_prop_t prop, int value) {
|
||||||
|
return (s != nullptr &&
|
||||||
|
s->s->set_bool(static_cast<Prop>(prop), value != 0))
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fgl_session_set_string(fgl_session_t* s, fgl_prop_t prop,
|
||||||
|
const char* value) {
|
||||||
|
return (s != nullptr &&
|
||||||
|
s->s->set_string(static_cast<Prop>(prop), value))
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fgl_session_get_prop(fgl_session_t* s, fgl_prop_t prop) {
|
||||||
|
return (s != nullptr && s->s->get_prop(static_cast<Prop>(prop))) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fgl_session_batch_begin(fgl_session_t* s) {
|
||||||
|
return (s != nullptr && s->s->batch_begin()) ? 1 : 0;
|
||||||
|
}
|
||||||
|
int fgl_session_batch_commit(fgl_session_t* s) {
|
||||||
|
return (s != nullptr && s->s->batch_commit()) ? 1 : 0;
|
||||||
|
}
|
||||||
|
int fgl_session_batch_abort(fgl_session_t* s) {
|
||||||
|
return (s != nullptr && s->s->batch_abort()) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fgl_session_cached(const fgl_session_t* s, fgl_prop_t prop,
|
||||||
|
fgl_value_t* out) {
|
||||||
|
if (s == nullptr) return 0;
|
||||||
|
Value v{};
|
||||||
|
if (!s->s->cached(static_cast<Prop>(prop), &v)) return 0;
|
||||||
|
if (out != nullptr) {
|
||||||
|
out->kind = static_cast<fgl_value_kind_t>(v.kind);
|
||||||
|
out->i = v.i;
|
||||||
|
memcpy(out->s, v.s, sizeof(out->s));
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Интроспекция
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
int fgl_template_detect(const char* oem_model) {
|
||||||
|
// Контракт c_api.h: -1 — неизвестная модель (template_detect дал бы kA).
|
||||||
|
if (!fgl::aircon::template_is_known(oem_model)) return -1;
|
||||||
|
return static_cast<int>(fgl::aircon::template_detect(oem_model));
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* fgl_prop_name(fgl_template_t t, fgl_prop_t prop) {
|
||||||
|
const PropInfo* info = fgl::aircon::prop_info_find(static_cast<Template>(t),
|
||||||
|
static_cast<Prop>(prop));
|
||||||
|
return info != nullptr ? info->name : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* fgl_prop_base_type(fgl_template_t t, fgl_prop_t prop) {
|
||||||
|
const PropInfo* info = fgl::aircon::prop_info_find(static_cast<Template>(t),
|
||||||
|
static_cast<Prop>(prop));
|
||||||
|
return info != nullptr ? info->base_type : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
int fgl_prop_read_only(fgl_template_t t, fgl_prop_t prop) {
|
||||||
|
const PropInfo* info = fgl::aircon::prop_info_find(static_cast<Template>(t),
|
||||||
|
static_cast<Prop>(prop));
|
||||||
|
return info != nullptr ? (info->read_only ? 1 : 0) : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t fgl_convert_to_display(fgl_template_t t, fgl_prop_t prop,
|
||||||
|
int64_t raw) {
|
||||||
|
return fgl::aircon::convert_to_display(static_cast<Template>(t),
|
||||||
|
static_cast<Prop>(prop), raw, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t fgl_convert_from_input(fgl_template_t t, fgl_prop_t prop,
|
||||||
|
int64_t api_value) {
|
||||||
|
return fgl::aircon::convert_from_input(static_cast<Template>(t),
|
||||||
|
static_cast<Prop>(prop), api_value,
|
||||||
|
nullptr);
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
// Реализация публичной сессии fgl-aircon поверх ayla-сессии.
|
||||||
|
#include "fgl-aircon/session.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <new>
|
||||||
|
|
||||||
|
#include "ayla/log.hpp"
|
||||||
|
#include "ayla/session.hpp"
|
||||||
|
|
||||||
|
namespace fgl::aircon {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct LogCtx {
|
||||||
|
LogSink fn = nullptr;
|
||||||
|
void* user = nullptr;
|
||||||
|
};
|
||||||
|
LogCtx g_log_ctx;
|
||||||
|
|
||||||
|
void log_trampoline(int level, const char* msg, size_t len, void*) {
|
||||||
|
if (g_log_ctx.fn != nullptr) {
|
||||||
|
g_log_ctx.fn(level, msg, len, g_log_ctx.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void set_log_sink(LogSink sink, void* ctx) {
|
||||||
|
g_log_ctx.fn = sink;
|
||||||
|
g_log_ctx.user = ctx;
|
||||||
|
ayla::log::set_sink(sink != nullptr ? log_trampoline : nullptr, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_log_level(int min_level) { ayla::log::set_min_level(min_level); }
|
||||||
|
|
||||||
|
struct Session::Impl {
|
||||||
|
Config cfg{};
|
||||||
|
Callbacks cbs{};
|
||||||
|
Template tmpl = Template::kA;
|
||||||
|
ayla::Session* ayla = nullptr;
|
||||||
|
|
||||||
|
struct CacheEntry {
|
||||||
|
Value value;
|
||||||
|
bool present = false;
|
||||||
|
};
|
||||||
|
CacheEntry cache[static_cast<size_t>(Prop::kCount)];
|
||||||
|
|
||||||
|
// Спинлок (no-heap): сериализует запись кэша (колбэк ayla) и чтение
|
||||||
|
// (cached()). Критические секции — копии POD ≤ 80 Б.
|
||||||
|
volatile int spin = 0;
|
||||||
|
void lock() {
|
||||||
|
while (__sync_lock_test_and_set(&spin, 1)) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void unlock() { __sync_lock_release(&spin); }
|
||||||
|
|
||||||
|
static void on_ayla_state(void* ctx, ayla::SessionState st,
|
||||||
|
ayla::SessionError err);
|
||||||
|
static void on_ayla_property(void* ctx, const ayla::PropertyEvent& ev);
|
||||||
|
|
||||||
|
const PropOverride* overrides() const { return cfg.overrides; }
|
||||||
|
};
|
||||||
|
|
||||||
|
void Session::Impl::on_ayla_state(void* ctx, ayla::SessionState st,
|
||||||
|
ayla::SessionError err) {
|
||||||
|
auto* impl = static_cast<Impl*>(ctx);
|
||||||
|
if (impl->cbs.on_state != nullptr) {
|
||||||
|
impl->cbs.on_state(impl->cbs.ctx, static_cast<State>(st),
|
||||||
|
static_cast<Error>(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Session::Impl::on_ayla_property(void* ctx,
|
||||||
|
const ayla::PropertyEvent& ev) {
|
||||||
|
auto* impl = static_cast<Impl*>(ctx);
|
||||||
|
const PropInfo* info = prop_info_find_by_name(impl->tmpl, ev.name);
|
||||||
|
if (info == nullptr) return; // свойство не из шаблона
|
||||||
|
|
||||||
|
Value v{};
|
||||||
|
if (ev.is_int) {
|
||||||
|
// Прибор шлёт bool-свойства как int 0/1 — коэрсим к типу из таблицы.
|
||||||
|
v.kind = (info->kind == ValueKind::kBool) ? ValueKind::kBool
|
||||||
|
: ValueKind::kInt;
|
||||||
|
v.i = convert_to_display(impl->tmpl, info->prop, ev.int_value,
|
||||||
|
impl->overrides());
|
||||||
|
} else if (ev.is_bool) {
|
||||||
|
v.kind = ValueKind::kBool;
|
||||||
|
v.i = ev.bool_value ? 1 : 0;
|
||||||
|
} else {
|
||||||
|
v.kind = ValueKind::kString;
|
||||||
|
snprintf(v.s, sizeof(v.s), "%s", ev.str_value);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
impl->lock();
|
||||||
|
auto& entry = impl->cache[static_cast<size_t>(info->prop)];
|
||||||
|
entry.value = v;
|
||||||
|
entry.present = true;
|
||||||
|
impl->unlock();
|
||||||
|
}
|
||||||
|
if (impl->cbs.on_property != nullptr) {
|
||||||
|
PropertyEvent out{};
|
||||||
|
out.prop = info->prop;
|
||||||
|
out.value = v;
|
||||||
|
out.cmd_id = ev.cmd_id;
|
||||||
|
out.status = ev.status;
|
||||||
|
impl->cbs.on_property(impl->cbs.ctx, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Session::Session() : impl_(nullptr) {}
|
||||||
|
|
||||||
|
Session::~Session() {
|
||||||
|
if (impl_ != nullptr) {
|
||||||
|
stop();
|
||||||
|
delete impl_->ayla;
|
||||||
|
delete impl_;
|
||||||
|
impl_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Session* Session::create(const Config& cfg, const Callbacks& cbs) {
|
||||||
|
if (cfg.host == nullptr || cfg.dsn == nullptr || cfg.lanip_key == nullptr) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (cfg.overrides != nullptr) {
|
||||||
|
// Валидация списка: терминатор Prop::kCount (обязателен, контракт
|
||||||
|
// документирован в types.hpp); linear без нулевых делителей; custom_fn
|
||||||
|
// с ненулевым fn. Сканирование ограничено kCount+1 элементами.
|
||||||
|
bool term = false;
|
||||||
|
for (ptrdiff_t i = 0;; i++) {
|
||||||
|
if (i > static_cast<ptrdiff_t>(Prop::kCount)) return nullptr;
|
||||||
|
const PropOverride& o = cfg.overrides[i];
|
||||||
|
if (o.prop == Prop::kCount) {
|
||||||
|
term = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (static_cast<uint8_t>(o.prop) >=
|
||||||
|
static_cast<uint8_t>(Prop::kCount)) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (o.to_display.kind == ConvKind::kLinear &&
|
||||||
|
(o.to_display.linear.num == 0 || o.to_display.linear.den == 0)) {
|
||||||
|
return nullptr; // деление на ноль
|
||||||
|
}
|
||||||
|
if (o.from_input.kind == ConvKind::kLinear &&
|
||||||
|
(o.from_input.linear.num == 0 || o.from_input.linear.den == 0)) {
|
||||||
|
return nullptr; // деление на ноль
|
||||||
|
}
|
||||||
|
if (o.to_display.kind == ConvKind::kCustomFn &&
|
||||||
|
o.to_display.fn == nullptr) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (o.from_input.kind == ConvKind::kCustomFn &&
|
||||||
|
o.from_input.fn == nullptr) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!term) return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* impl = new (std::nothrow) Session::Impl();
|
||||||
|
if (impl == nullptr) return nullptr;
|
||||||
|
impl->cfg = cfg;
|
||||||
|
impl->cbs = cbs;
|
||||||
|
impl->tmpl = cfg.tmpl;
|
||||||
|
|
||||||
|
ayla::SessionConfig acfg{};
|
||||||
|
acfg.host = cfg.host;
|
||||||
|
acfg.device_port = cfg.device_port;
|
||||||
|
acfg.dsn = cfg.dsn;
|
||||||
|
acfg.lanip_key = cfg.lanip_key;
|
||||||
|
acfg.lanip_key_id = cfg.lanip_key_id;
|
||||||
|
acfg.listen_port = cfg.listen_port;
|
||||||
|
acfg.keepalive_ms = cfg.keepalive_ms;
|
||||||
|
acfg.max_queue = cfg.max_queue;
|
||||||
|
ayla::SessionCallbacks acbs{};
|
||||||
|
acbs.on_state = &Session::Impl::on_ayla_state;
|
||||||
|
acbs.on_property = &Session::Impl::on_ayla_property;
|
||||||
|
acbs.ctx = impl;
|
||||||
|
impl->ayla = ayla::Session::create(acfg, acbs);
|
||||||
|
if (impl->ayla == nullptr) {
|
||||||
|
delete impl;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* s = new (std::nothrow) Session();
|
||||||
|
if (s == nullptr) {
|
||||||
|
delete impl->ayla;
|
||||||
|
delete impl;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
s->impl_ = impl;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::start() {
|
||||||
|
return impl_ != nullptr && impl_->ayla != nullptr && impl_->ayla->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Session::stop() {
|
||||||
|
if (impl_ != nullptr && impl_->ayla != nullptr) {
|
||||||
|
impl_->ayla->stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
State Session::state() const {
|
||||||
|
if (impl_ == nullptr || impl_->ayla == nullptr) return State::kIdle;
|
||||||
|
return static_cast<State>(impl_->ayla->state());
|
||||||
|
}
|
||||||
|
|
||||||
|
Error Session::last_error() const {
|
||||||
|
if (impl_ == nullptr || impl_->ayla == nullptr) return Error::kNone;
|
||||||
|
return static_cast<Error>(impl_->ayla->last_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::set_int(Prop prop, int64_t value) {
|
||||||
|
if (impl_ == nullptr ||
|
||||||
|
static_cast<uint8_t>(prop) >= static_cast<uint8_t>(Prop::kCount)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const PropInfo* info = prop_info_find(impl_->tmpl, prop);
|
||||||
|
if (info == nullptr || info->read_only || info->kind == ValueKind::kString) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int64_t lo = 0, hi = 0;
|
||||||
|
if (prop_api_range(impl_->tmpl, prop, impl_->overrides(), &lo, &hi)) {
|
||||||
|
if (value < lo) value = lo;
|
||||||
|
if (value > hi) value = hi;
|
||||||
|
}
|
||||||
|
int64_t raw =
|
||||||
|
convert_from_input(impl_->tmpl, prop, value, impl_->overrides());
|
||||||
|
bool ok = impl_->ayla->set_property(info->name, raw, info->base_type);
|
||||||
|
if (ok) {
|
||||||
|
// Оптимистичный кэш (эха нет — PROTOCOL §5.3).
|
||||||
|
impl_->lock();
|
||||||
|
auto& entry = impl_->cache[static_cast<size_t>(prop)];
|
||||||
|
entry.value.kind = info->kind;
|
||||||
|
entry.value.i = value;
|
||||||
|
entry.present = true;
|
||||||
|
impl_->unlock();
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::set_bool(Prop prop, bool value) {
|
||||||
|
if (impl_ == nullptr) return false;
|
||||||
|
const PropInfo* info = prop_info_find(impl_->tmpl, prop);
|
||||||
|
if (info == nullptr || info->kind != ValueKind::kBool) return false;
|
||||||
|
return set_int(prop, value ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::set_string(Prop prop, const char* value) {
|
||||||
|
if (impl_ == nullptr || value == nullptr) return false;
|
||||||
|
const PropInfo* info = prop_info_find(impl_->tmpl, prop);
|
||||||
|
if (info == nullptr || info->read_only ||
|
||||||
|
info->kind != ValueKind::kString) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool ok = impl_->ayla->set_property_string(info->name, value);
|
||||||
|
if (ok) {
|
||||||
|
impl_->lock();
|
||||||
|
auto& entry = impl_->cache[static_cast<size_t>(prop)];
|
||||||
|
snprintf(entry.value.s, sizeof(entry.value.s), "%s", value);
|
||||||
|
entry.value.kind = ValueKind::kString;
|
||||||
|
entry.present = true;
|
||||||
|
impl_->unlock();
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::get_prop(Prop prop) {
|
||||||
|
if (impl_ == nullptr) return false;
|
||||||
|
const PropInfo* info = prop_info_find(impl_->tmpl, prop);
|
||||||
|
if (info == nullptr) return false;
|
||||||
|
return impl_->ayla->get_property(info->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::batch_begin() {
|
||||||
|
return impl_ != nullptr && impl_->ayla != nullptr &&
|
||||||
|
impl_->ayla->begin_batch();
|
||||||
|
}
|
||||||
|
bool Session::batch_commit() {
|
||||||
|
return impl_ != nullptr && impl_->ayla != nullptr &&
|
||||||
|
impl_->ayla->commit_batch();
|
||||||
|
}
|
||||||
|
bool Session::batch_abort() {
|
||||||
|
return impl_ != nullptr && impl_->ayla != nullptr &&
|
||||||
|
impl_->ayla->abort_batch();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::cached(Prop prop, Value* out) const {
|
||||||
|
if (impl_ == nullptr ||
|
||||||
|
static_cast<uint8_t>(prop) >= static_cast<uint8_t>(Prop::kCount)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
impl_->lock();
|
||||||
|
const auto& entry = impl_->cache[static_cast<size_t>(prop)];
|
||||||
|
bool present = entry.present;
|
||||||
|
if (present && out != nullptr) *out = entry.value;
|
||||||
|
impl_->unlock();
|
||||||
|
return present;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t Session::rekey_count() const {
|
||||||
|
return impl_ != nullptr && impl_->ayla != nullptr
|
||||||
|
? impl_->ayla->rekey_count()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
uint32_t Session::pushes_ok() const {
|
||||||
|
return impl_ != nullptr && impl_->ayla != nullptr
|
||||||
|
? impl_->ayla->pushes_ok()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
uint32_t Session::pushes_bad() const {
|
||||||
|
return impl_ != nullptr && impl_->ayla != nullptr
|
||||||
|
? impl_->ayla->pushes_bad()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
uint32_t Session::commands_served() const {
|
||||||
|
return impl_ != nullptr && impl_->ayla != nullptr
|
||||||
|
? impl_->ayla->commands_served()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgl::aircon
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
// Таблицы свойств шаблонов A/B/F (docs/PROTOCOL.md §8.1-8.2, по данным APK).
|
||||||
|
#include "fgl-aircon/templates.hpp"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace fgl::aircon {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// raw-диапазоны: PROTOCOL §8.3 (таблица приложения −10..45 °C; фактические
|
||||||
|
// ограничения прибора уже; направления — по num_dir, таймеры/прочее — 0..N).
|
||||||
|
constexpr PropInfo kTemplateA[] = {
|
||||||
|
{Prop::OperationMode, "operation_mode", "integer", false, ValueKind::kInt, 0, 6},
|
||||||
|
{Prop::FanSpeed, "fan_speed", "integer", false, ValueKind::kInt, 0, 4},
|
||||||
|
{Prop::AdjustTemperature, "adjust_temperature", "integer", false, ValueKind::kInt, -100, 450},
|
||||||
|
{Prop::DisplayTemperature, "display_temperature", "integer", true, ValueKind::kInt, 4000, 9500},
|
||||||
|
{Prop::AfVerticalDirection, "af_vertical_direction", "integer", false, ValueKind::kInt, 0, 15},
|
||||||
|
{Prop::AfVerticalSwing, "af_vertical_swing", "integer", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::AfHorizontalDirection, "af_horizontal_direction", "integer", false, ValueKind::kInt, 0, 15},
|
||||||
|
{Prop::AfHorizontalSwing, "af_horizontal_swing", "integer", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::OutdoorLowNoise, "outdoor_low_noise", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::IndoorFanControl, "indoor_fan_control", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::HumanDetAutoSave, "human_det_auto_save", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::MinHeat, "min_heat", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::PowerfulMode, "powerful_mode", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::CoilDryMode, "coil_dry_mode", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::EconomyMode, "economy_mode", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::MasterTimerOnOff1, "master_timer_on_off_1", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::MasterTimerOnOff2, "master_timer_on_off_2", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::ErrorCode, "error_code", "integer", true, ValueKind::kInt, 0, 4095},
|
||||||
|
{Prop::DemandControl, "demand_control", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::FilterSignResetDisplay, "filter_sign_reset_display", "integer", false, ValueKind::kInt, 0, 1},
|
||||||
|
{Prop::OpStatus, "op_status", "integer", true, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::DeviceName, "device_name", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::BuildingName, "building_name", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::WifiLedEnable, "wifi_led_enable", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::ServiceContactName, "service_contact_name", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::ServiceContactPhone, "service_contact_phone", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::ServiceContactEmail, "service_contact_email", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::AfHorizontalNumDir, "af_horizontal_num_dir", "integer", true, ValueKind::kInt, 0, 15},
|
||||||
|
{Prop::AfVerticalNumDir, "af_vertical_num_dir", "integer", true, ValueKind::kInt, 0, 15},
|
||||||
|
{Prop::DeviceCapabilities, "device_capabilities", "integer", true, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::GetProp, "get_prop", "integer", false, ValueKind::kInt, 0, 1},
|
||||||
|
{Prop::HumanDet, "human_det", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::Refresh, "refresh", "integer", false, ValueKind::kInt, 0, 1},
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr PropInfo kTemplateB[] = {
|
||||||
|
{Prop::OperationMode, "operation_mode", "integer", false, ValueKind::kInt, 0, 6},
|
||||||
|
{Prop::FanSpeed, "fan_speed", "integer", false, ValueKind::kInt, 0, 4},
|
||||||
|
{Prop::AdjustTemperature, "adjust_temperature", "integer", false, ValueKind::kInt, -100, 450},
|
||||||
|
{Prop::AfVerticalMoveStep1, "af_vertical_move_step1", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::AfHorizontalMoveStep1, "af_horizontal_move_step1", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::EconomyMode, "economy_mode", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::MasterTimerOnOff1, "master_timer_on_off_1", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::MasterTimerOnOff2, "master_timer_on_off_2", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::ErrorCode, "error_code", "integer", true, ValueKind::kInt, 0, 4095},
|
||||||
|
{Prop::DemandControl, "demand_control", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::FilterSignResetDisplay, "filter_sign_reset_display", "integer", false, ValueKind::kInt, 0, 1},
|
||||||
|
{Prop::OpStatus, "op_status", "integer", true, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::DeviceName, "device_name", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::BuildingName, "building_name", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::WifiLedEnable, "wifi_led_enable", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::ServiceContactName, "service_contact_name", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::ServiceContactPhone, "service_contact_phone", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::ServiceContactEmail, "service_contact_email", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::DeviceCapabilities, "device_capabilities", "integer", true, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::Refresh, "refresh", "integer", false, ValueKind::kInt, 0, 1},
|
||||||
|
};
|
||||||
|
|
||||||
|
// F = A + monitor1, filter_sign_reset вместо filter_sign_reset_display.
|
||||||
|
constexpr PropInfo kTemplateF[] = {
|
||||||
|
{Prop::OperationMode, "operation_mode", "integer", false, ValueKind::kInt, 0, 6},
|
||||||
|
{Prop::FanSpeed, "fan_speed", "integer", false, ValueKind::kInt, 0, 4},
|
||||||
|
{Prop::AdjustTemperature, "adjust_temperature", "integer", false, ValueKind::kInt, -100, 450},
|
||||||
|
{Prop::DisplayTemperature, "display_temperature", "integer", true, ValueKind::kInt, 4000, 9500},
|
||||||
|
{Prop::AfVerticalDirection, "af_vertical_direction", "integer", false, ValueKind::kInt, 0, 15},
|
||||||
|
{Prop::AfVerticalSwing, "af_vertical_swing", "integer", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::AfHorizontalDirection, "af_horizontal_direction", "integer", false, ValueKind::kInt, 0, 15},
|
||||||
|
{Prop::AfHorizontalSwing, "af_horizontal_swing", "integer", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::OutdoorLowNoise, "outdoor_low_noise", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::IndoorFanControl, "indoor_fan_control", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::HumanDetAutoSave, "human_det_auto_save", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::MinHeat, "min_heat", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::PowerfulMode, "powerful_mode", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::CoilDryMode, "coil_dry_mode", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::EconomyMode, "economy_mode", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::MasterTimerOnOff1, "master_timer_on_off_1", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::MasterTimerOnOff2, "master_timer_on_off_2", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::ErrorCode, "error_code", "integer", true, ValueKind::kInt, 0, 4095},
|
||||||
|
{Prop::DemandControl, "demand_control", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::FilterSignReset, "filter_sign_reset", "integer", false, ValueKind::kInt, 0, 1},
|
||||||
|
{Prop::OpStatus, "op_status", "integer", true, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::DeviceName, "device_name", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::BuildingName, "building_name", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::WifiLedEnable, "wifi_led_enable", "boolean", false, ValueKind::kBool, 0, 1},
|
||||||
|
{Prop::ServiceContactName, "service_contact_name", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::ServiceContactPhone, "service_contact_phone", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::ServiceContactEmail, "service_contact_email", "string", false, ValueKind::kString, 0, 0},
|
||||||
|
{Prop::AfHorizontalNumDir, "af_horizontal_num_dir", "integer", true, ValueKind::kInt, 0, 15},
|
||||||
|
{Prop::AfVerticalNumDir, "af_vertical_num_dir", "integer", true, ValueKind::kInt, 0, 15},
|
||||||
|
{Prop::DeviceCapabilities, "device_capabilities", "integer", true, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::GetProp, "get_prop", "integer", false, ValueKind::kInt, 0, 1},
|
||||||
|
{Prop::HumanDet, "human_det", "integer", false, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::Monitor1, "monitor1", "integer", true, ValueKind::kInt, 0, 0},
|
||||||
|
{Prop::Refresh, "refresh", "integer", false, ValueKind::kInt, 0, 1},
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr const char* kOemA[] = {"AP-WA1E", "AP-WA2E", "AP-WA3E", "AP-WA4E",
|
||||||
|
"AP-WA5E", "AP-WA6E", "AP-WC1E", "AP-WC2E",
|
||||||
|
"AP-WC3E", "AP-WC4E", "AP-WD1E"};
|
||||||
|
constexpr const char* kOemB[] = {"AP-WB1E", "AP-WB2E", "AP-WB3E", "AP-WB4E"};
|
||||||
|
constexpr const char* kOemF[] = {"AP-WF1E", "AP-WF2E", "AP-WF3E", "AP-WF4E"};
|
||||||
|
|
||||||
|
bool in_list(const char* const* list, size_t n, const char* model) {
|
||||||
|
for (size_t i = 0; i < n; i++) {
|
||||||
|
if (strcmp(list[i], model) == 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr size_t kOemACount = sizeof(kOemA) / sizeof(kOemA[0]);
|
||||||
|
constexpr size_t kOemBCount = sizeof(kOemB) / sizeof(kOemB[0]);
|
||||||
|
constexpr size_t kOemFCount = sizeof(kOemF) / sizeof(kOemF[0]);
|
||||||
|
|
||||||
|
const PropInfo* table_begin(Template t) {
|
||||||
|
switch (t) {
|
||||||
|
case Template::kA: return kTemplateA;
|
||||||
|
case Template::kB: return kTemplateB;
|
||||||
|
case Template::kF: return kTemplateF;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t table_count(Template t) {
|
||||||
|
switch (t) {
|
||||||
|
case Template::kA: return sizeof(kTemplateA) / sizeof(kTemplateA[0]);
|
||||||
|
case Template::kB: return sizeof(kTemplateB) / sizeof(kTemplateB[0]);
|
||||||
|
case Template::kF: return sizeof(kTemplateF) / sizeof(kTemplateF[0]);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Деление int64 с округлением к нулю; b != 0 (валидация в create);
|
||||||
|
// INT64_MIN/-1 → насыщение (иначе UB).
|
||||||
|
int64_t div_round_zero(int64_t a, int64_t b) {
|
||||||
|
// Нативное / уже усекает к нулю; единственный UB-случай — INT64_MIN / -1.
|
||||||
|
if (a == INT64_MIN && b == -1) return INT64_MAX;
|
||||||
|
return a / b;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Conversion* pick_override(const PropOverride* overrides, Prop prop,
|
||||||
|
bool to_display) {
|
||||||
|
if (overrides == nullptr) return nullptr;
|
||||||
|
for (const PropOverride* o = overrides; o->prop != Prop::kCount; o++) {
|
||||||
|
if (o->prop == prop) {
|
||||||
|
return to_display ? &o->to_display : &o->from_input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PropOverride* find_override(const PropOverride* overrides, Prop prop) {
|
||||||
|
if (overrides == nullptr) return nullptr;
|
||||||
|
for (const PropOverride* o = overrides; o->prop != Prop::kCount; o++) {
|
||||||
|
if (o->prop == prop) return o;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool template_is_known(const char* oem_model) {
|
||||||
|
if (oem_model == nullptr) return false;
|
||||||
|
return in_list(kOemA, kOemACount, oem_model) ||
|
||||||
|
in_list(kOemB, kOemBCount, oem_model) ||
|
||||||
|
in_list(kOemF, kOemFCount, oem_model);
|
||||||
|
}
|
||||||
|
|
||||||
|
Template template_detect(const char* oem_model) {
|
||||||
|
if (oem_model == nullptr) {
|
||||||
|
// Эквивалент kA, чтобы API без модели работал; явная проверка у вызывающего.
|
||||||
|
return Template::kA;
|
||||||
|
}
|
||||||
|
if (in_list(kOemA, kOemACount, oem_model)) return Template::kA;
|
||||||
|
if (in_list(kOemB, kOemBCount, oem_model)) return Template::kB;
|
||||||
|
if (in_list(kOemF, kOemFCount, oem_model)) return Template::kF;
|
||||||
|
return Template::kA; // неизвестные модели трактуем как A (как и legacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PropInfo* prop_info_begin(Template t) { return table_begin(t); }
|
||||||
|
size_t prop_info_count(Template t) { return table_count(t); }
|
||||||
|
|
||||||
|
const PropInfo* prop_info_find(Template t, Prop prop) {
|
||||||
|
for (const PropInfo* p = table_begin(t);
|
||||||
|
p != table_begin(t) + table_count(t); p++) {
|
||||||
|
if (p->prop == prop) return p;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PropInfo* prop_info_find_by_name(Template t, const char* name) {
|
||||||
|
if (name == nullptr) return nullptr;
|
||||||
|
for (const PropInfo* p = table_begin(t);
|
||||||
|
p != table_begin(t) + table_count(t); p++) {
|
||||||
|
if (strcmp(p->name, name) == 0) return p;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* prop_enum_name(Prop prop) {
|
||||||
|
switch (prop) {
|
||||||
|
case Prop::OperationMode: return "OperationMode";
|
||||||
|
case Prop::FanSpeed: return "FanSpeed";
|
||||||
|
case Prop::AdjustTemperature: return "AdjustTemperature";
|
||||||
|
case Prop::DisplayTemperature: return "DisplayTemperature";
|
||||||
|
case Prop::AfVerticalDirection: return "AfVerticalDirection";
|
||||||
|
case Prop::AfVerticalSwing: return "AfVerticalSwing";
|
||||||
|
case Prop::AfHorizontalDirection: return "AfHorizontalDirection";
|
||||||
|
case Prop::AfHorizontalSwing: return "AfHorizontalSwing";
|
||||||
|
case Prop::AfVerticalMoveStep1: return "AfVerticalMoveStep1";
|
||||||
|
case Prop::AfHorizontalMoveStep1: return "AfHorizontalMoveStep1";
|
||||||
|
case Prop::OutdoorLowNoise: return "OutdoorLowNoise";
|
||||||
|
case Prop::IndoorFanControl: return "IndoorFanControl";
|
||||||
|
case Prop::HumanDetAutoSave: return "HumanDetAutoSave";
|
||||||
|
case Prop::MinHeat: return "MinHeat";
|
||||||
|
case Prop::PowerfulMode: return "PowerfulMode";
|
||||||
|
case Prop::CoilDryMode: return "CoilDryMode";
|
||||||
|
case Prop::EconomyMode: return "EconomyMode";
|
||||||
|
case Prop::MasterTimerOnOff1: return "MasterTimerOnOff1";
|
||||||
|
case Prop::MasterTimerOnOff2: return "MasterTimerOnOff2";
|
||||||
|
case Prop::ErrorCode: return "ErrorCode";
|
||||||
|
case Prop::DemandControl: return "DemandControl";
|
||||||
|
case Prop::FilterSignResetDisplay: return "FilterSignResetDisplay";
|
||||||
|
case Prop::FilterSignReset: return "FilterSignReset";
|
||||||
|
case Prop::OpStatus: return "OpStatus";
|
||||||
|
case Prop::DeviceName: return "DeviceName";
|
||||||
|
case Prop::BuildingName: return "BuildingName";
|
||||||
|
case Prop::WifiLedEnable: return "WifiLedEnable";
|
||||||
|
case Prop::ServiceContactName: return "ServiceContactName";
|
||||||
|
case Prop::ServiceContactPhone: return "ServiceContactPhone";
|
||||||
|
case Prop::ServiceContactEmail: return "ServiceContactEmail";
|
||||||
|
case Prop::AfHorizontalNumDir: return "AfHorizontalNumDir";
|
||||||
|
case Prop::AfVerticalNumDir: return "AfVerticalNumDir";
|
||||||
|
case Prop::DeviceCapabilities: return "DeviceCapabilities";
|
||||||
|
case Prop::GetProp: return "GetProp";
|
||||||
|
case Prop::HumanDet: return "HumanDet";
|
||||||
|
case Prop::Monitor1: return "Monitor1";
|
||||||
|
case Prop::Refresh: return "Refresh";
|
||||||
|
case Prop::kCount: break;
|
||||||
|
}
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t convert_to_display(Template t, Prop prop, int64_t raw,
|
||||||
|
const PropOverride* overrides) {
|
||||||
|
(void)t; // шаблонных не-тождественных конверсий пока нет (кроме общей ниже)
|
||||||
|
const Conversion* c = pick_override(overrides, prop, /*to_display=*/true);
|
||||||
|
if (c != nullptr) {
|
||||||
|
switch (c->kind) {
|
||||||
|
case ConvKind::kLinear: {
|
||||||
|
int64_t prod = 0;
|
||||||
|
if (__builtin_mul_overflow(raw, c->linear.num, &prod)) {
|
||||||
|
prod = (raw < 0) != (c->linear.num < 0) ? INT64_MIN : INT64_MAX;
|
||||||
|
}
|
||||||
|
int64_t q = div_round_zero(prod, c->linear.den);
|
||||||
|
int64_t out = 0;
|
||||||
|
if (__builtin_add_overflow(q, c->linear.offset, &out)) {
|
||||||
|
out = q < 0 ? INT64_MIN : INT64_MAX;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
case ConvKind::kCustomFn:
|
||||||
|
return c->fn != nullptr ? c->fn(raw, c->ctx) : raw;
|
||||||
|
case ConvKind::kTemplateDefault:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Шаблонная конверсия по умолчанию.
|
||||||
|
if (prop == Prop::DisplayTemperature) {
|
||||||
|
int64_t diff = 0;
|
||||||
|
if (__builtin_sub_overflow(raw, 5000, &diff)) {
|
||||||
|
diff = INT64_MIN; // raw < INT64_MIN+5000 — дальше только насыщение
|
||||||
|
}
|
||||||
|
return div_round_zero(diff, 10); // raw 0.01°C(+5000) -> API 0.1°C
|
||||||
|
}
|
||||||
|
return raw; // остальные свойства — тождественны
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t convert_from_input(Template t, Prop prop, int64_t api_value,
|
||||||
|
const PropOverride* overrides) {
|
||||||
|
(void)t;
|
||||||
|
const Conversion* c = pick_override(overrides, prop, /*to_display=*/false);
|
||||||
|
if (c != nullptr) {
|
||||||
|
switch (c->kind) {
|
||||||
|
case ConvKind::kLinear: {
|
||||||
|
// Инверсия: raw = (api - offset) * den / num (с насыщением).
|
||||||
|
int64_t diff = 0;
|
||||||
|
// Направление насыщения: по знаку уменьшаемого (offset с экстремумом
|
||||||
|
// сам бы переполнил любую разность).
|
||||||
|
if (__builtin_sub_overflow(api_value, c->linear.offset, &diff)) {
|
||||||
|
diff = api_value < 0 ? INT64_MIN : INT64_MAX;
|
||||||
|
}
|
||||||
|
int64_t prod = 0;
|
||||||
|
if (__builtin_mul_overflow(diff, c->linear.den, &prod)) {
|
||||||
|
prod = (diff < 0) != (c->linear.den < 0) ? INT64_MIN : INT64_MAX;
|
||||||
|
}
|
||||||
|
return div_round_zero(prod, c->linear.num);
|
||||||
|
}
|
||||||
|
case ConvKind::kCustomFn:
|
||||||
|
return c->fn != nullptr ? c->fn(api_value, c->ctx) : api_value;
|
||||||
|
case ConvKind::kTemplateDefault:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prop == Prop::DisplayTemperature) {
|
||||||
|
int64_t prod = 0;
|
||||||
|
if (__builtin_mul_overflow(api_value, 10, &prod)) {
|
||||||
|
prod = api_value < 0 ? INT64_MIN : INT64_MAX;
|
||||||
|
}
|
||||||
|
int64_t out = 0;
|
||||||
|
if (__builtin_add_overflow(prod, 5000, &out)) {
|
||||||
|
out = prod < 0 ? INT64_MIN : INT64_MAX;
|
||||||
|
}
|
||||||
|
return out; // API 0.1°C -> raw (read-only, но симметрично)
|
||||||
|
}
|
||||||
|
return api_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool prop_api_range(Template t, Prop prop, const PropOverride* overrides,
|
||||||
|
int64_t* min, int64_t* max) {
|
||||||
|
const PropOverride* o = find_override(overrides, prop);
|
||||||
|
if (o != nullptr && o->has_range) {
|
||||||
|
if (min != nullptr) *min = o->min;
|
||||||
|
if (max != nullptr) *max = o->max;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const PropInfo* info = prop_info_find(t, prop);
|
||||||
|
if (info == nullptr || info->raw_min == info->raw_max) {
|
||||||
|
return false; // нет диапазона (0..0 в таблице — «не определён»)
|
||||||
|
}
|
||||||
|
int64_t lo = convert_to_display(t, prop, info->raw_min, overrides);
|
||||||
|
int64_t hi = convert_to_display(t, prop, info->raw_max, overrides);
|
||||||
|
if (lo > hi) {
|
||||||
|
int64_t tmp = lo;
|
||||||
|
lo = hi;
|
||||||
|
hi = tmp;
|
||||||
|
}
|
||||||
|
if (min != nullptr) *min = lo;
|
||||||
|
if (max != nullptr) *max = hi;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgl::aircon
|
||||||
+61
-12
@@ -18,16 +18,26 @@ namespace {
|
|||||||
|
|
||||||
constexpr uint32_t kLoopTickMs = 20;
|
constexpr uint32_t kLoopTickMs = 20;
|
||||||
constexpr size_t kMaxName = 40;
|
constexpr size_t kMaxName = 40;
|
||||||
constexpr size_t kMaxQueue = 32;
|
constexpr size_t kMaxQueue = 48; // >= cfg.max_queue default 40 (полный батч
|
||||||
|
// шаблона A = 33 свойства) и с запасом
|
||||||
|
static_assert(kMaxQueue >= 40, "kMaxQueue must cover full template batch");
|
||||||
|
|
||||||
struct Command {
|
struct Command {
|
||||||
uint8_t type; // 1=GET, 2=SET, 3=DELETE
|
uint8_t type; // 1=GET, 2=SET, 3=DELETE
|
||||||
char name[kMaxName];
|
char name[kMaxName];
|
||||||
int64_t value;
|
int64_t value;
|
||||||
char base_type[10];
|
char base_type[10];
|
||||||
|
char str_value[48]; // строковые SET
|
||||||
int cmd_id;
|
int cmd_id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Безопасная копия строк (без snprintf — избегаем -Wrestrict при инлайне).
|
||||||
|
void copy_str(char* dst, const char* src, size_t cap) {
|
||||||
|
size_t i = 0;
|
||||||
|
for (; i + 1 < cap && src[i] != '\0'; i++) dst[i] = src[i];
|
||||||
|
dst[i] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
bool gen_random_token(char* out, size_t len) {
|
bool gen_random_token(char* out, size_t len) {
|
||||||
static const char kAlpha[] =
|
static const char kAlpha[] =
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
@@ -143,6 +153,8 @@ struct Session::Impl {
|
|||||||
strncmp(queue[i].name, cmd.name, kMaxName) == 0) {
|
strncmp(queue[i].name, cmd.name, kMaxName) == 0) {
|
||||||
if (cmd.type == 2) {
|
if (cmd.type == 2) {
|
||||||
queue[i].value = cmd.value; // замещаем
|
queue[i].value = cmd.value; // замещаем
|
||||||
|
copy_str(queue[i].str_value, cmd.str_value,
|
||||||
|
sizeof(queue[i].str_value));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (cmd.type == 1) return true; // дубликат GET
|
if (cmd.type == 1) return true; // дубликат GET
|
||||||
@@ -165,6 +177,8 @@ struct Session::Impl {
|
|||||||
strncmp(batch[i].name, cmd.name, kMaxName) == 0) {
|
strncmp(batch[i].name, cmd.name, kMaxName) == 0) {
|
||||||
if (cmd.type == 2) {
|
if (cmd.type == 2) {
|
||||||
batch[i].value = cmd.value;
|
batch[i].value = cmd.value;
|
||||||
|
copy_str(batch[i].str_value, cmd.str_value,
|
||||||
|
sizeof(batch[i].str_value));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (cmd.type == 1) return true;
|
if (cmd.type == 1) return true;
|
||||||
@@ -370,6 +384,8 @@ void Session::Impl::build_set_payload(char* out, size_t out_cap,
|
|||||||
w.key("value");
|
w.key("value");
|
||||||
if (strcmp(c.base_type, "boolean") == 0) {
|
if (strcmp(c.base_type, "boolean") == 0) {
|
||||||
w.boolean(c.value != 0);
|
w.boolean(c.value != 0);
|
||||||
|
} else if (strcmp(c.base_type, "string") == 0) {
|
||||||
|
w.string(c.str_value);
|
||||||
} else {
|
} else {
|
||||||
w.integer(c.value);
|
w.integer(c.value);
|
||||||
}
|
}
|
||||||
@@ -522,20 +538,31 @@ void Session::Impl::handle_datapoint(const HttpRequest& req,
|
|||||||
snprintf(ev.name, sizeof(ev.name), "%s", name);
|
snprintf(ev.name, sizeof(ev.name), "%s", name);
|
||||||
int64_t iv = 0;
|
int64_t iv = 0;
|
||||||
bool bv = false;
|
bool bv = false;
|
||||||
const char* sv = nullptr;
|
|
||||||
size_t sv_len = 0;
|
|
||||||
jsmntype_t vt;
|
|
||||||
if (data_doc.get_int("value", &iv)) {
|
if (data_doc.get_int("value", &iv)) {
|
||||||
ev.is_int = true;
|
ev.is_int = true;
|
||||||
ev.int_value = iv;
|
ev.int_value = iv;
|
||||||
} else if (data_doc.get_bool("value", &bv)) {
|
} else if (data_doc.get_bool("value", &bv)) {
|
||||||
ev.is_bool = true;
|
ev.is_bool = true;
|
||||||
ev.bool_value = bv;
|
ev.bool_value = bv;
|
||||||
} else if (data_doc.find("value", &sv, &sv_len, &vt) &&
|
|
||||||
vt == JSMN_STRING && sv_len + 1 <= sizeof(ev.str_value)) {
|
|
||||||
memcpy(ev.str_value, sv, sv_len);
|
|
||||||
ev.str_value[sv_len] = '\0';
|
|
||||||
}
|
}
|
||||||
|
bool value_ok = true;
|
||||||
|
if (ev.is_int || ev.is_bool) {
|
||||||
|
value_ok = true;
|
||||||
|
} else {
|
||||||
|
// Строка: значение должно влезать в буфер события; иначе событие
|
||||||
|
// не отправляем (кэш не затираем пустышкой).
|
||||||
|
const char* sv2 = nullptr;
|
||||||
|
size_t sv2_len = 0;
|
||||||
|
jsmntype_t vt2;
|
||||||
|
value_ok = data_doc.find("value", &sv2, &sv2_len, &vt2) &&
|
||||||
|
vt2 == JSMN_STRING &&
|
||||||
|
sv2_len + 1 <= sizeof(ev.str_value);
|
||||||
|
if (value_ok) {
|
||||||
|
memcpy(ev.str_value, sv2, sv2_len);
|
||||||
|
ev.str_value[sv2_len] = '\0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value_ok) {
|
||||||
int cmd_id = -1, status = 0;
|
int cmd_id = -1, status = 0;
|
||||||
parse_query(req.query, &cmd_id, &status);
|
parse_query(req.query, &cmd_id, &status);
|
||||||
ev.cmd_id = cmd_id;
|
ev.cmd_id = cmd_id;
|
||||||
@@ -547,6 +574,7 @@ void Session::Impl::handle_datapoint(const HttpRequest& req,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pushes_ok.fetch_add(1, std::memory_order_relaxed);
|
pushes_ok.fetch_add(1, std::memory_order_relaxed);
|
||||||
resp.status = 200;
|
resp.status = 200;
|
||||||
}
|
}
|
||||||
@@ -717,7 +745,7 @@ Session* Session::create(const SessionConfig& cfg, const SessionCallbacks& cbs)
|
|||||||
if (impl == nullptr) return nullptr;
|
if (impl == nullptr) return nullptr;
|
||||||
impl->cfg = cfg;
|
impl->cfg = cfg;
|
||||||
impl->cbs = cbs;
|
impl->cbs = cbs;
|
||||||
if (impl->cfg.max_queue == 0) impl->cfg.max_queue = 16;
|
if (impl->cfg.max_queue == 0) impl->cfg.max_queue = 40;
|
||||||
if (impl->cfg.keepalive_ms == 0) impl->cfg.keepalive_ms = 15000;
|
if (impl->cfg.keepalive_ms == 0) impl->cfg.keepalive_ms = 15000;
|
||||||
impl->timings.keepalive_ms = impl->cfg.keepalive_ms;
|
impl->timings.keepalive_ms = impl->cfg.keepalive_ms;
|
||||||
snprintf(impl->host, sizeof(impl->host), "%s", cfg.host);
|
snprintf(impl->host, sizeof(impl->host), "%s", cfg.host);
|
||||||
@@ -860,6 +888,26 @@ void Session::set_timings_for_test(const SessionTimings& t) {
|
|||||||
impl_->timings = tmp;
|
impl_->timings = tmp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Session::set_property_string(const char* name, const char* value) {
|
||||||
|
if (impl_ == nullptr || name == nullptr || value == nullptr ||
|
||||||
|
strlen(name) >= kMaxName || strlen(value) >= 48) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Command cmd{};
|
||||||
|
cmd.type = 2;
|
||||||
|
snprintf(cmd.name, sizeof(cmd.name), "%s", name);
|
||||||
|
cmd.value = 0;
|
||||||
|
snprintf(cmd.base_type, sizeof(cmd.base_type), "string");
|
||||||
|
snprintf(cmd.str_value, sizeof(cmd.str_value), "%s", value);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
||||||
|
cmd.cmd_id = impl_->next_cmd_id++;
|
||||||
|
}
|
||||||
|
if (!impl_->submit(cmd)) return false;
|
||||||
|
impl_->want_notify.store(true, std::memory_order_release);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool Session::begin_batch() {
|
bool Session::begin_batch() {
|
||||||
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
||||||
if (impl_->batch_open) return false;
|
if (impl_->batch_open) return false;
|
||||||
@@ -869,19 +917,20 @@ bool Session::begin_batch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool Session::commit_batch() {
|
bool Session::commit_batch() {
|
||||||
|
bool ok = true;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
||||||
if (!impl_->batch_open) return false;
|
if (!impl_->batch_open) return false;
|
||||||
bool ok = true;
|
|
||||||
for (uint8_t i = 0; i < impl_->batch_len; i++) {
|
for (uint8_t i = 0; i < impl_->batch_len; i++) {
|
||||||
if (!impl_->enqueue_locked(impl_->batch[i])) ok = false;
|
if (!impl_->enqueue_locked(impl_->batch[i])) ok = false;
|
||||||
}
|
}
|
||||||
impl_->batch_open = false;
|
impl_->batch_open = false;
|
||||||
impl_->batch_len = 0;
|
impl_->batch_len = 0;
|
||||||
if (!ok) return false;
|
|
||||||
}
|
}
|
||||||
|
// notify ставится ВСЕГДА: даже при частичном отказе поставленные команды
|
||||||
|
// должны уйти немедленно, а не ждать keep-alive.
|
||||||
impl_->want_notify.store(true, std::memory_order_release);
|
impl_->want_notify.store(true, std::memory_order_release);
|
||||||
return true;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Session::abort_batch() {
|
bool Session::abort_batch() {
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ struct SessionConfig {
|
|||||||
uint32_t lanip_key_id = 0;
|
uint32_t lanip_key_id = 0;
|
||||||
uint16_t listen_port = 10275; // 0 — любой свободный
|
uint16_t listen_port = 10275; // 0 — любой свободный
|
||||||
uint32_t keepalive_ms = 15000;
|
uint32_t keepalive_ms = 15000;
|
||||||
uint8_t max_queue = 16; // лимит очереди команд
|
uint8_t max_queue = 40; // лимит очереди команд (>= полного
|
||||||
|
// батча свойств шаблона, 33)
|
||||||
};
|
};
|
||||||
|
|
||||||
struct SessionCallbacks {
|
struct SessionCallbacks {
|
||||||
@@ -107,6 +108,8 @@ class Session {
|
|||||||
// SET-команда (integer/boolean как int64).
|
// SET-команда (integer/boolean как int64).
|
||||||
bool set_property(const char* name, int64_t value,
|
bool set_property(const char* name, int64_t value,
|
||||||
const char* base_type = "integer");
|
const char* base_type = "integer");
|
||||||
|
// SET-команда строкового свойства (base_type "string").
|
||||||
|
bool set_property_string(const char* name, const char* value);
|
||||||
// Пакет: собрать несколько команд, один notify на commit.
|
// Пакет: собрать несколько команд, один notify на commit.
|
||||||
bool begin_batch();
|
bool begin_batch();
|
||||||
bool commit_batch();
|
bool commit_batch();
|
||||||
|
|||||||
@@ -34,9 +34,29 @@ fgl_add_test(ayla_envelope ayla/test_envelope.cpp)
|
|||||||
fgl_add_test(ayla_json ayla/test_json.cpp)
|
fgl_add_test(ayla_json ayla/test_json.cpp)
|
||||||
fgl_add_test(ayla_httpc ayla/test_httpc.cpp)
|
fgl_add_test(ayla_httpc ayla/test_httpc.cpp)
|
||||||
|
|
||||||
|
# aircon-слой: юнит-тесты таблиц/конверсий + C-API smoke.
|
||||||
|
fgl_add_test(aircon_tables aircon/test_tables.cpp)
|
||||||
|
fgl_add_test(aircon_convert aircon/test_convert.cpp)
|
||||||
|
target_link_libraries(test_aircon_tables PRIVATE fgl-aircon)
|
||||||
|
target_link_libraries(test_aircon_convert PRIVATE fgl-aircon)
|
||||||
|
|
||||||
|
add_executable(aircon_runner aircon/aircon_runner.cpp)
|
||||||
|
target_compile_features(aircon_runner PRIVATE cxx_std_20)
|
||||||
|
target_link_libraries(aircon_runner PRIVATE fgl-aircon)
|
||||||
|
target_include_directories(aircon_runner PRIVATE "${CMAKE_SOURCE_DIR}/src")
|
||||||
|
|
||||||
# Интеграционные сценарии с mock-модулем (python, stdlib-only).
|
# Интеграционные сценарии с mock-модулем (python, stdlib-only).
|
||||||
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
||||||
add_test(NAME ayla_session_mock
|
add_test(NAME ayla_session_mock
|
||||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/ayla/test_session_mock.py
|
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/ayla/test_session_mock.py
|
||||||
$<TARGET_FILE:session_runner> ${CMAKE_CURRENT_SOURCE_DIR}/ayla/mock_ac.py)
|
$<TARGET_FILE:session_runner> ${CMAKE_CURRENT_SOURCE_DIR}/ayla/mock_ac.py)
|
||||||
set_tests_properties(ayla_session_mock PROPERTIES TIMEOUT 180)
|
set_tests_properties(ayla_session_mock PROPERTIES TIMEOUT 180)
|
||||||
|
add_test(NAME aircon_session_mock
|
||||||
|
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/aircon/test_aircon_mock.py
|
||||||
|
$<TARGET_FILE:aircon_runner> ${CMAKE_CURRENT_SOURCE_DIR}/ayla/mock_ac.py)
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Тесты публичной сессии (aircon-слой) против mock-модуля: маппинг
|
||||||
|
// имя↔enum, конверсии в событиях, оптимистичный кэш, batch, C-API.
|
||||||
|
// Запускается python-оркестратором (tests/aircon/test_aircon_mock.py).
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
#include "fgl-aircon/c_api.h"
|
||||||
|
#include "fgl-aircon/session.hpp"
|
||||||
|
#include "fgl-aircon/templates.hpp"
|
||||||
|
|
||||||
|
using fgl::aircon::Prop;
|
||||||
|
using fgl::aircon::Session;
|
||||||
|
using fgl::aircon::State;
|
||||||
|
|
||||||
|
static 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 "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_state(void*, State st, fgl::aircon::Error err) {
|
||||||
|
printf("STATE %s %d\n", state_name(st), static_cast<int>(err));
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_property(void*, 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 %d %d s:%s\n", n, ev.cmd_id, ev.status, ev.value.s);
|
||||||
|
} else {
|
||||||
|
printf("PROP %s %d %d %c:%lld\n", n, ev.cmd_id, ev.status,
|
||||||
|
ev.value.kind == fgl::aircon::ValueKind::kBool ? 'b' : 'i',
|
||||||
|
static_cast<long long>(ev.value.i));
|
||||||
|
}
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t env_u32(const char* name, uint32_t def) {
|
||||||
|
const char* v = getenv(name);
|
||||||
|
return v != nullptr ? static_cast<uint32_t>(atoi(v)) : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
if (argc < 6) {
|
||||||
|
fprintf(stderr,
|
||||||
|
"usage: %s <host> <device_port> <listen_port> <dsn> <lanip_key> "
|
||||||
|
"<key_id> <duration_sec> [prop-enum-name ...]\n",
|
||||||
|
argv[0]);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
fgl::aircon::Config cfg{};
|
||||||
|
cfg.host = argv[1];
|
||||||
|
cfg.device_port = static_cast<uint16_t>(atoi(argv[2]));
|
||||||
|
cfg.listen_port = static_cast<uint16_t>(atoi(argv[3]));
|
||||||
|
cfg.dsn = argv[4];
|
||||||
|
cfg.lanip_key = argv[5];
|
||||||
|
cfg.lanip_key_id = static_cast<uint32_t>(atoi(argv[6]));
|
||||||
|
cfg.tmpl = fgl::aircon::Template::kA;
|
||||||
|
int duration_sec = atoi(argv[7]);
|
||||||
|
|
||||||
|
fgl::aircon::Callbacks cbs{};
|
||||||
|
cbs.on_state = on_state;
|
||||||
|
cbs.on_property = on_property;
|
||||||
|
|
||||||
|
Session* s = Session::create(cfg, cbs);
|
||||||
|
if (s == nullptr) {
|
||||||
|
printf("CREATE_FAILED\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
// Тест C-API интроспекции.
|
||||||
|
printf("CVER %s\n", fgl_version());
|
||||||
|
printf("CNAME %s\n", fgl_prop_name(FGL_TEMPLATE_A, FGL_PROP_FAN_SPEED));
|
||||||
|
printf("CCONV %lld\n", static_cast<long long>(
|
||||||
|
fgl_convert_to_display(FGL_TEMPLATE_A,
|
||||||
|
FGL_PROP_DISPLAY_TEMPERATURE,
|
||||||
|
7000)));
|
||||||
|
|
||||||
|
if (!s->start()) {
|
||||||
|
printf("START_FAILED\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начальная синхронизация: пакет GET всех свойств шаблона A (батч).
|
||||||
|
if (argc > 8) {
|
||||||
|
s->batch_begin();
|
||||||
|
for (int i = 8; i < argc; i++) {
|
||||||
|
// argv: имена enum'ов ("OperationMode")
|
||||||
|
for (uint8_t p = 0; p < static_cast<uint8_t>(Prop::kCount); p++) {
|
||||||
|
if (strcmp(fgl::aircon::prop_enum_name(static_cast<Prop>(p)),
|
||||||
|
argv[i]) == 0) {
|
||||||
|
if (!s->get_prop(static_cast<Prop>(p))) {
|
||||||
|
printf("GET_FAIL %s\n", argv[i]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!s->batch_commit()) printf("BATCH_PARTIAL\n");
|
||||||
|
} else {
|
||||||
|
// По умолчанию — все int/bool свойства шаблола A одним батчем
|
||||||
|
// (строковые пропускаются: модуль-мок их не генерирует; на приборе
|
||||||
|
// строковый push проверялся вручную через aircon-события).
|
||||||
|
s->batch_begin();
|
||||||
|
for (auto* info = fgl::aircon::prop_info_begin(cfg.tmpl);
|
||||||
|
info != fgl::aircon::prop_info_begin(cfg.tmpl) +
|
||||||
|
fgl::aircon::prop_info_count(cfg.tmpl);
|
||||||
|
info++) {
|
||||||
|
if (info->kind != fgl::aircon::ValueKind::kString) {
|
||||||
|
if (!s->get_prop(info->prop)) {
|
||||||
|
printf("GET_FAIL %s\n", fgl::aircon::prop_enum_name(info->prop));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!s->batch_commit()) printf("BATCH_PARTIAL\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* set_prop = getenv("RUNNER_SET_PROP"); // enum-имя
|
||||||
|
const char* set_val = getenv("RUNNER_SET_VALUE");
|
||||||
|
uint32_t set_at = env_u32("RUNNER_SET_AT", 0);
|
||||||
|
if (set_prop != nullptr && set_val != nullptr && set_at > 0) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(set_at));
|
||||||
|
for (uint8_t p = 0; p < static_cast<uint8_t>(Prop::kCount); p++) {
|
||||||
|
if (strcmp(fgl::aircon::prop_enum_name(static_cast<Prop>(p)),
|
||||||
|
set_prop) == 0) {
|
||||||
|
bool ok = s->set_int(static_cast<Prop>(p), atoll(set_val));
|
||||||
|
printf("SET_DONE %s=%s ok=%d\n", set_prop, set_val, ok ? 1 : 0);
|
||||||
|
// Оптимистичный кэш сразу доступен.
|
||||||
|
fgl::aircon::Value v{};
|
||||||
|
if (s->cached(static_cast<Prop>(p), &v)) {
|
||||||
|
printf("CACHED %s %lld\n", set_prop,
|
||||||
|
static_cast<long long>(v.i));
|
||||||
|
}
|
||||||
|
fflush(stdout);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto deadline = std::chrono::steady_clock::now() +
|
||||||
|
std::chrono::seconds(duration_sec);
|
||||||
|
while (std::chrono::steady_clock::now() < deadline) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверки отказов (после duration, до stop):
|
||||||
|
// - read-only свойство (ErrorCode) — отказ;
|
||||||
|
// - свойство не из шаблона A (AfVerticalMoveStep1, только B) — отказ;
|
||||||
|
// - клэмпинг: AdjustTemperature=1000 -> raw max 450.
|
||||||
|
printf("RO_SET %d\n", s->set_int(Prop::ErrorCode, 5) ? 1 : 0);
|
||||||
|
printf("NOTMPL_SET %d\n",
|
||||||
|
s->set_int(Prop::AfVerticalMoveStep1, 1) ? 1 : 0);
|
||||||
|
printf("CLAMP_SET %d\n", s->set_int(Prop::AdjustTemperature, 1000) ? 1 : 0);
|
||||||
|
|
||||||
|
s->stop();
|
||||||
|
printf("DELETED\n");
|
||||||
|
printf("STATS rekeys=%u pushes_ok=%u pushes_bad=%u cmds=%u state=%s\n",
|
||||||
|
s->rekey_count(), s->pushes_ok(), s->pushes_bad(),
|
||||||
|
s->commands_served(), state_name(s->state()));
|
||||||
|
fflush(stdout);
|
||||||
|
delete s;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Интеграционные сценарии aircon-слоя (публичный API) против mock-модуля.
|
||||||
|
|
||||||
|
usage: test_aircon_mock.py <aircon_runner> <mock_ac.py>
|
||||||
|
Проверяет: маппинг enum↔имя, конверсию в событиях (DisplayTemperature!),
|
||||||
|
оптимистичный кэш, batch=1 notify, RO/не-шаблонные отказы, клэмпинг.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "ayla"))
|
||||||
|
from test_session_mock import ( # noqa: E402
|
||||||
|
FAILURES, Proc, check, free_port, LANIP_KEY, KEY_ID, DSN, run_mock)
|
||||||
|
|
||||||
|
RUNNER = sys.argv[1]
|
||||||
|
MOCK = sys.argv[2]
|
||||||
|
|
||||||
|
|
||||||
|
def run_runner(mock_port, listen, duration, props=None, env_extra=None,
|
||||||
|
timeout=None):
|
||||||
|
env = dict(os.environ)
|
||||||
|
if env_extra:
|
||||||
|
env.update(env_extra)
|
||||||
|
argv = [RUNNER, "127.0.0.1", str(mock_port), str(listen), DSN, LANIP_KEY,
|
||||||
|
str(KEY_ID), str(duration)]
|
||||||
|
if props:
|
||||||
|
argv += props
|
||||||
|
p = Proc(argv, env=env)
|
||||||
|
return p, (timeout or duration + 25)
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_mapping_and_batch():
|
||||||
|
print("== aircon: маппинг/конверсии, batch=1 notify, полный шаблон A")
|
||||||
|
mock_port, listen = free_port(), free_port()
|
||||||
|
mock = run_mock([], mock_port)
|
||||||
|
runner, timeout = run_runner(mock_port, listen, 5,
|
||||||
|
props=["OperationMode", "FanSpeed",
|
||||||
|
"DisplayTemperature"])
|
||||||
|
check(runner.wait_line("STATE online", 10) is not None, "online")
|
||||||
|
p1 = runner.wait_line("PROP OperationMode", 10)
|
||||||
|
check(p1 is not None and " i:6" in p1, f"operation_mode: {p1}")
|
||||||
|
p2 = runner.wait_line("PROP FanSpeed", 10)
|
||||||
|
check(p2 is not None and " i:4" in p2, f"fan_speed: {p2}")
|
||||||
|
# Конверсия: mock шлёт display_temperature=7000? Нет — mock вернёт
|
||||||
|
# значение из своих props (нет в словаре -> 0) => raw 0 -> api -500.
|
||||||
|
p3 = runner.wait_line("PROP DisplayTemperature", 10)
|
||||||
|
check(p3 is not None and " i:-500" in p3, f"display_temp raw0 -> -500: {p3}")
|
||||||
|
# C-API smoke.
|
||||||
|
check(runner.wait_line("CVER 0.", 5) is not None, "c_api version")
|
||||||
|
cname = runner.wait_line("CNAME", 5)
|
||||||
|
check(cname is not None and "fan_speed" in cname, "c_api prop_name")
|
||||||
|
conv = runner.wait_line("CCONV 200", 5)
|
||||||
|
check(conv is not None, "c_api convert 7000 -> 200")
|
||||||
|
# batch=1 notify: ждём >=2 REG (второй — асинхронный put после выдачи
|
||||||
|
# батча), затем ассертим ровно один notify=True.
|
||||||
|
import time as _time
|
||||||
|
deadline = _time.time() + 5
|
||||||
|
while _time.time() < deadline:
|
||||||
|
regs = [l for l in mock.all_lines() if l.startswith("REG")]
|
||||||
|
if len(regs) >= 2:
|
||||||
|
break
|
||||||
|
_time.sleep(0.2)
|
||||||
|
notifies = [l for l in regs if "notify=True" in l]
|
||||||
|
check(len(regs) >= 2 and len(notifies) == 1,
|
||||||
|
f"один notify=True на батч: regs={regs}")
|
||||||
|
runner.wait_line("DELETED", timeout)
|
||||||
|
runner.stop()
|
||||||
|
mock.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_set_cache_clamp():
|
||||||
|
print("== aircon: SET + оптимистичный кэш + клэмпинг + отказы")
|
||||||
|
mock_port, listen = free_port(), free_port()
|
||||||
|
mock = run_mock([], mock_port)
|
||||||
|
runner, timeout = run_runner(mock_port, listen, 5,
|
||||||
|
env_extra={"RUNNER_SET_PROP": "FanSpeed",
|
||||||
|
"RUNNER_SET_VALUE": "3",
|
||||||
|
"RUNNER_SET_AT": "1"})
|
||||||
|
check(runner.wait_line("STATE online", 10) is not None, "online")
|
||||||
|
check(runner.wait_line("~SET_DONE FanSpeed=3 ok=1", 10) is not None,
|
||||||
|
"SET выполнена")
|
||||||
|
check(runner.wait_line("CACHED FanSpeed 3", 5) is not None,
|
||||||
|
"оптимистичный кэш")
|
||||||
|
check(mock.wait_line("~CMD SET fan_speed=3", 10) is not None,
|
||||||
|
"mock применил SET (имя из таблицы)")
|
||||||
|
# Отказы и клэмпинг печатаются перед DELETED.
|
||||||
|
check(runner.wait_line("RO_SET 0", timeout) is not None, "RO отказ")
|
||||||
|
check(runner.wait_line("NOTMPL_SET 0", 5) is not None,
|
||||||
|
"не-шаблонное свойство отказ")
|
||||||
|
check(runner.wait_line("CLAMP_SET 1", 5) is not None, "клэмпинг применён")
|
||||||
|
check(mock.wait_line("~CMD SET adjust_temperature=450", 15) is not None,
|
||||||
|
"клэмпинг: 1000 -> raw max 450 на модуле")
|
||||||
|
runner.wait_line("DELETED", 10)
|
||||||
|
runner.stop()
|
||||||
|
mock.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
scenario_mapping_and_batch()
|
||||||
|
scenario_set_cache_clamp()
|
||||||
|
print()
|
||||||
|
if FAILURES:
|
||||||
|
print("ПРОВАЛЕНО:", len(FAILURES))
|
||||||
|
for f in FAILURES:
|
||||||
|
print(" -", f)
|
||||||
|
sys.exit(1)
|
||||||
|
print("ВСЕ СЦЕНАРИИ ПРОЙДЕНЫ")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Тесты конверсий: шаблонные по умолчанию, linear/custom override, диапазоны.
|
||||||
|
#include "doctest/doctest.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "fgl-aircon/templates.hpp"
|
||||||
|
|
||||||
|
using fgl::aircon::ConvKind;
|
||||||
|
using fgl::aircon::Conversion;
|
||||||
|
using fgl::aircon::Linear;
|
||||||
|
using fgl::aircon::Prop;
|
||||||
|
using fgl::aircon::PropOverride;
|
||||||
|
using fgl::aircon::convert_from_input;
|
||||||
|
using fgl::aircon::convert_to_display;
|
||||||
|
using fgl::aircon::prop_api_range;
|
||||||
|
using fgl::aircon::Template;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
const PropOverride kTerm{Prop::kCount};
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Конверсия по умолчанию: тождество и DisplayTemperature") {
|
||||||
|
// Большинство свойств тождественны (API == raw).
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::OperationMode, 6, &kTerm) == 6);
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, 200, &kTerm) == 200);
|
||||||
|
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, 250, &kTerm) == 250);
|
||||||
|
|
||||||
|
// DisplayTemperature: raw 0.01°C(+5000) -> API 0.1°C.
|
||||||
|
// Прибор: 7000 → 20.0°C → 200 (проверено на приборе).
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 7000, &kTerm) == 200);
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 7300, &kTerm) == 230);
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 4975, &kTerm) == -2);
|
||||||
|
// Округление к нулю.
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 7004, &kTerm) == 200);
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 7005, &kTerm) == 200);
|
||||||
|
// Обратная (симметричная, для тестов; свойство RO).
|
||||||
|
CHECK(convert_from_input(Template::kA, Prop::DisplayTemperature, 230, &kTerm) == 7300);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Linear override: to_display и from_input") {
|
||||||
|
// ТЕ ЖЕ коэффициенты в оба направления (from_input = инверсия):
|
||||||
|
// api = raw*1/2 (250 -> 125); raw = api*2 (125 -> 250).
|
||||||
|
PropOverride ov{};
|
||||||
|
ov.prop = Prop::AdjustTemperature;
|
||||||
|
ov.to_display.kind = ConvKind::kLinear;
|
||||||
|
ov.to_display.linear = Linear{1, 2, 0};
|
||||||
|
ov.from_input.kind = ConvKind::kLinear;
|
||||||
|
ov.from_input.linear = Linear{1, 2, 0};
|
||||||
|
const PropOverride list[] = {ov, kTerm};
|
||||||
|
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, 250, list) == 125);
|
||||||
|
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, 125, list) == 250);
|
||||||
|
|
||||||
|
// Смещение: Фаренгейт из сырых 0.1°C: F = raw*9/5 + 320 (те же коэфф.).
|
||||||
|
PropOverride ovF{};
|
||||||
|
ovF.prop = Prop::AdjustTemperature;
|
||||||
|
ovF.to_display.kind = ConvKind::kLinear;
|
||||||
|
ovF.to_display.linear = Linear{9, 5, 320};
|
||||||
|
ovF.from_input.kind = ConvKind::kLinear;
|
||||||
|
ovF.from_input.linear = Linear{9, 5, 320};
|
||||||
|
const PropOverride listF[] = {ovF, kTerm};
|
||||||
|
// 25.0°C = 77°F: 250*9/5+320 = 770 (0.1°F).
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, 250, listF) == 770);
|
||||||
|
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, 770, listF) == 250);
|
||||||
|
// Отрицательные: -100 (−10°C) → 140 (14.0°F).
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, -100, listF) == 140);
|
||||||
|
|
||||||
|
// Override не задет другие свойства.
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::FanSpeed, 3, listF) == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
int64_t double_it(int64_t raw, void*) { return raw * 2; }
|
||||||
|
int64_t half_it(int64_t v, void*) { return v / 2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Custom fn override") {
|
||||||
|
PropOverride ov{};
|
||||||
|
ov.prop = Prop::FanSpeed;
|
||||||
|
ov.to_display.kind = ConvKind::kCustomFn;
|
||||||
|
ov.to_display.fn = double_it;
|
||||||
|
ov.from_input.kind = ConvKind::kCustomFn;
|
||||||
|
ov.from_input.fn = half_it;
|
||||||
|
const PropOverride list[] = {ov, kTerm};
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::FanSpeed, 2, list) == 4);
|
||||||
|
CHECK(convert_from_input(Template::kA, Prop::FanSpeed, 4, list) == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Диапазоны API-значений: шаблонные и override") {
|
||||||
|
int64_t lo = 0, hi = 0;
|
||||||
|
// adjust_temperature: raw -100..450 → тождественно.
|
||||||
|
REQUIRE(prop_api_range(Template::kA, Prop::AdjustTemperature, &kTerm, &lo, &hi));
|
||||||
|
CHECK(lo == -100);
|
||||||
|
CHECK(hi == 450);
|
||||||
|
// display_temperature: raw 4000..9500 (таблица приложения −10..45°C)
|
||||||
|
// → api -100..450.
|
||||||
|
REQUIRE(prop_api_range(Template::kA, Prop::DisplayTemperature, &kTerm, &lo, &hi));
|
||||||
|
CHECK(lo == -100);
|
||||||
|
CHECK(hi == 450);
|
||||||
|
// error_code 0..4095.
|
||||||
|
REQUIRE(prop_api_range(Template::kA, Prop::ErrorCode, &kTerm, &lo, &hi));
|
||||||
|
CHECK(lo == 0);
|
||||||
|
CHECK(hi == 4095);
|
||||||
|
// Без диапазона (0..0 в таблице).
|
||||||
|
CHECK_FALSE(prop_api_range(Template::kA, Prop::DeviceName, &kTerm, &lo, &hi));
|
||||||
|
CHECK_FALSE(prop_api_range(Template::kA, Prop::OpStatus, &kTerm, &lo, &hi));
|
||||||
|
|
||||||
|
// Override-диапазон перекрывает шаблонный.
|
||||||
|
PropOverride ov{};
|
||||||
|
ov.prop = Prop::AdjustTemperature;
|
||||||
|
ov.has_range = true;
|
||||||
|
ov.min = 160;
|
||||||
|
ov.max = 300; // 16..30°C — фактические границы прибора
|
||||||
|
const PropOverride list[] = {ov, kTerm};
|
||||||
|
REQUIRE(prop_api_range(Template::kA, Prop::AdjustTemperature, list, &lo, &hi));
|
||||||
|
CHECK(lo == 160);
|
||||||
|
CHECK(hi == 300);
|
||||||
|
|
||||||
|
// Свойство не из шаблона.
|
||||||
|
CHECK_FALSE(prop_api_range(Template::kB, Prop::AfVerticalDirection, &kTerm,
|
||||||
|
&lo, &hi));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST_CASE("Насыщение linear при переполнении int64") {
|
||||||
|
// to_display: api = raw*num/den; raw=4 * num=INT64_MAX/2 -> переполнение.
|
||||||
|
PropOverride ov{};
|
||||||
|
ov.prop = Prop::AdjustTemperature;
|
||||||
|
ov.to_display.kind = ConvKind::kLinear;
|
||||||
|
ov.to_display.linear = Linear{INT64_MAX / 2, 1, 0};
|
||||||
|
// from_input: raw = (api-offset)*den/num; api=4 * den=INT64_MAX/2.
|
||||||
|
ov.from_input.kind = ConvKind::kLinear;
|
||||||
|
ov.from_input.linear = Linear{1, INT64_MAX / 2, 0};
|
||||||
|
const PropOverride list[] = {ov, kTerm};
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, 4, list) ==
|
||||||
|
INT64_MAX);
|
||||||
|
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, 4, list) ==
|
||||||
|
INT64_MAX);
|
||||||
|
// Смещение с переполнением сложения.
|
||||||
|
PropOverride ov2{};
|
||||||
|
ov2.prop = Prop::FanSpeed;
|
||||||
|
ov2.to_display.kind = ConvKind::kLinear;
|
||||||
|
ov2.to_display.linear = Linear{1, 1, INT64_MAX};
|
||||||
|
const PropOverride list2[] = {ov2, kTerm};
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::FanSpeed, 2, list2) ==
|
||||||
|
INT64_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("template_is_known") {
|
||||||
|
using fgl::aircon::template_is_known;
|
||||||
|
CHECK(template_is_known("AP-WC1E"));
|
||||||
|
CHECK_FALSE(template_is_known("UNKNOWN"));
|
||||||
|
CHECK_FALSE(template_is_known(nullptr));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST_CASE("Насыщение linear: негативные экстремумы (UBSan)") {
|
||||||
|
// raw=-4 * num=INT64_MAX/2 -> INT64_MIN-переполнение -> насыщение MIN.
|
||||||
|
PropOverride ov{};
|
||||||
|
ov.prop = Prop::AdjustTemperature;
|
||||||
|
ov.to_display.kind = ConvKind::kLinear;
|
||||||
|
ov.to_display.linear = Linear{INT64_MAX / 2, 1, 0};
|
||||||
|
ov.from_input.kind = ConvKind::kLinear;
|
||||||
|
ov.from_input.linear = Linear{1, INT64_MAX / 2, 0};
|
||||||
|
const PropOverride list[] = {ov, kTerm};
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, -4, list) ==
|
||||||
|
INT64_MIN); // (-4)*(MAX/2) -> переполнение в минус
|
||||||
|
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, -4, list) ==
|
||||||
|
INT64_MIN);
|
||||||
|
// add-переполнение в минус.
|
||||||
|
PropOverride ov2{};
|
||||||
|
ov2.prop = Prop::FanSpeed;
|
||||||
|
ov2.to_display.kind = ConvKind::kLinear;
|
||||||
|
ov2.to_display.linear = Linear{1, 1, INT64_MIN};
|
||||||
|
const PropOverride list2[] = {ov2, kTerm};
|
||||||
|
// q=2 + offset=INT64_MIN = INT64_MIN+2 — корректно (в диапазоне int64),
|
||||||
|
// насыщение не требуется; убеждаемся что нет падения/UB.
|
||||||
|
int64_t v = convert_to_display(Template::kA, Prop::FanSpeed, 2, list2);
|
||||||
|
CHECK(v == INT64_MIN + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("DisplayTemperature: экстремумы без UB") {
|
||||||
|
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature,
|
||||||
|
INT64_MIN + 1, &kTerm) != 0);
|
||||||
|
// Значение определяется насыщением, точный результат не важен — нет UB.
|
||||||
|
int64_t r = convert_from_input(Template::kA, Prop::DisplayTemperature,
|
||||||
|
INT64_MIN / 10, &kTerm);
|
||||||
|
CHECK(r != 0); // насыщение или корректное значение — но не падение
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Тесты таблиц шаблонов: состав против PROTOCOL.md §8.2, типы, RO, диапазоны.
|
||||||
|
#include "doctest/doctest.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "fgl-aircon/templates.hpp"
|
||||||
|
|
||||||
|
using fgl::aircon::Prop;
|
||||||
|
using fgl::aircon::prop_info_count;
|
||||||
|
using fgl::aircon::prop_info_find;
|
||||||
|
using fgl::aircon::prop_info_find_by_name;
|
||||||
|
using fgl::aircon::Template;
|
||||||
|
using fgl::aircon::template_detect;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Список имён свойств шаблона (для сверки с протоколом).
|
||||||
|
std::string names_joined(Template t) {
|
||||||
|
std::string out;
|
||||||
|
for (auto* p = fgl::aircon::prop_info_begin(t);
|
||||||
|
p != fgl::aircon::prop_info_begin(t) + prop_info_count(t); p++) {
|
||||||
|
if (!out.empty()) out += ",";
|
||||||
|
out += p->name;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool has(Template t, const char* name) {
|
||||||
|
return prop_info_find_by_name(t, name) != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("Шаблон A: состав против PROTOCOL §8.2 (все 33)") {
|
||||||
|
const char* expected[] = {
|
||||||
|
"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"};
|
||||||
|
for (const char* n : expected) {
|
||||||
|
CAPTURE(n);
|
||||||
|
CHECK(has(Template::kA, n));
|
||||||
|
}
|
||||||
|
CHECK(prop_info_count(Template::kA) == 33);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Шаблон B: состав (20) и отличия от A") {
|
||||||
|
CHECK(prop_info_count(Template::kB) == 20);
|
||||||
|
CHECK(has(Template::kB, "af_vertical_move_step1"));
|
||||||
|
CHECK(has(Template::kB, "af_horizontal_move_step1"));
|
||||||
|
CHECK_FALSE(has(Template::kB, "af_vertical_direction"));
|
||||||
|
CHECK_FALSE(has(Template::kB, "display_temperature"));
|
||||||
|
CHECK_FALSE(has(Template::kB, "powerful_mode"));
|
||||||
|
CHECK_FALSE(has(Template::kB, "min_heat"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Шаблон F: = A + monitor1 + filter_sign_reset (без display-варианта)") {
|
||||||
|
CHECK(prop_info_count(Template::kF) == 34);
|
||||||
|
CHECK(has(Template::kF, "monitor1"));
|
||||||
|
CHECK(has(Template::kF, "filter_sign_reset"));
|
||||||
|
CHECK_FALSE(has(Template::kF, "filter_sign_reset_display"));
|
||||||
|
CHECK(has(Template::kF, "display_temperature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("template_detect: oem_model → шаблон (PROTOCOL §8.1)") {
|
||||||
|
CHECK(template_detect("AP-WC1E") == Template::kA);
|
||||||
|
CHECK(template_detect("AP-WA3E") == Template::kA);
|
||||||
|
CHECK(template_detect("AP-WB2E") == Template::kB);
|
||||||
|
CHECK(template_detect("AP-WF4E") == Template::kF);
|
||||||
|
CHECK(template_detect("UNKNOWN-MODEL") == Template::kA); // как legacy
|
||||||
|
CHECK(template_detect(nullptr) == Template::kA);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Атрибуты свойств: base_type/read_only/диапазоны") {
|
||||||
|
auto* om = prop_info_find_by_name(Template::kA, "operation_mode");
|
||||||
|
REQUIRE(om != nullptr);
|
||||||
|
CHECK(std::string(om->base_type) == "integer");
|
||||||
|
CHECK_FALSE(om->read_only);
|
||||||
|
CHECK(om->raw_min == 0);
|
||||||
|
CHECK(om->raw_max == 6);
|
||||||
|
|
||||||
|
auto* err = prop_info_find_by_name(Template::kA, "error_code");
|
||||||
|
REQUIRE(err != nullptr);
|
||||||
|
CHECK(err->read_only);
|
||||||
|
CHECK(err->raw_max == 4095);
|
||||||
|
|
||||||
|
auto* eco = prop_info_find_by_name(Template::kA, "economy_mode");
|
||||||
|
REQUIRE(eco != nullptr);
|
||||||
|
CHECK(std::string(eco->base_type) == "boolean");
|
||||||
|
CHECK(eco->kind == fgl::aircon::ValueKind::kBool);
|
||||||
|
|
||||||
|
auto* dn = prop_info_find_by_name(Template::kA, "device_name");
|
||||||
|
REQUIRE(dn != nullptr);
|
||||||
|
CHECK(dn->kind == fgl::aircon::ValueKind::kString);
|
||||||
|
CHECK(std::string(dn->base_type) == "string");
|
||||||
|
|
||||||
|
// prop_info_find по enum согласован с find_by_name.
|
||||||
|
auto* by_enum = prop_info_find(Template::kA, Prop::FanSpeed);
|
||||||
|
REQUIRE(by_enum != nullptr);
|
||||||
|
CHECK(std::string(by_enum->name) == "fan_speed");
|
||||||
|
CHECK(prop_info_find(Template::kB, Prop::AfVerticalDirection) == nullptr);
|
||||||
|
CHECK(prop_info_find(Template::kA, Prop::kCount) == nullptr);
|
||||||
|
CHECK(prop_info_find_by_name(Template::kA, "no_such") == nullptr);
|
||||||
|
CHECK(prop_info_find_by_name(Template::kA, nullptr) == nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Таблицы: без дублей имен и enum'ов внутри шаблона") {
|
||||||
|
for (Template t : {Template::kA, Template::kB, Template::kF}) {
|
||||||
|
size_t n = prop_info_count(t);
|
||||||
|
for (size_t i = 0; i < n; i++) {
|
||||||
|
for (size_t j = i + 1; j < n; j++) {
|
||||||
|
auto* a = fgl::aircon::prop_info_begin(t) + i;
|
||||||
|
auto* b = fgl::aircon::prop_info_begin(t) + j;
|
||||||
|
CHECK_FALSE(std::string(a->name) == std::string(b->name));
|
||||||
|
CHECK_FALSE(a->prop == b->prop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("af_*_swing: base_type integer (PROTOCOL §8.3), kind bool в API") {
|
||||||
|
auto* vs = prop_info_find_by_name(Template::kA, "af_vertical_swing");
|
||||||
|
REQUIRE(vs != nullptr);
|
||||||
|
CHECK(std::string(vs->base_type) == "integer");
|
||||||
|
CHECK(vs->kind == fgl::aircon::ValueKind::kBool);
|
||||||
|
}
|
||||||
@@ -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