From fa1718f40520b5ceb577314b72447aa9f1f5f6c5 Mon Sep 17 00:00:00 2001 From: Petr Polezhaev Date: Mon, 28 Sep 2026 20:16:54 +0300 Subject: [PATCH] =?UTF-8?q?core(M3):=20aircon-=D1=81=D0=BB=D0=BE=D0=B9=20?= =?UTF-8?q?=E2=80=94=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D1=8B=20A/B/F,?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8+overr?= =?UTF-8?q?ide,=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20API=20+=20C-API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - include/fgl-aircon: types.hpp (Prop/State/Error/Value/Template/ Conversion-linear-custom/PropOverride/Config с задокументированными lifetime-контрактами), templates.hpp (PropInfo-таблицы, интроспекция, конверсии c контрактом Linear num/den!=0), session.hpp (Session: set_int/bool/string, get_prop, batch, cached, set_log_sink с контрактом), c_api.h (extern "C" для cffi/pyfglair). - src/aircon: tables.cpp (шаблоны A=33/B=20/F=34 по PROTOCOL §8.2, template_detect + template_is_known; конверсии: default тождественно, DisplayTemperature raw 0.01°C(+5000)→API 0.1°C; linear с инверсией raw=(api-offset)*den/num и насыщением __builtin_*_overflow; custom fn; af_*_swing base_type integer, kind bool), session.cpp (маппинг имя↔Prop, коэрсинг int-push к kBool по таблице, кэш со спинлоком, оптимистичный кэш, валидация overrides: терминатор/linear/custom_fn), c_api.cpp (шейм, static_assert'ы на все enum-значения, fgl_template_detect -1 для неизвестных). - ayla: set_property_string (Command.str_value, coalescing копирует строку в т.ч. в batch-ветке); kMaxQueue 48 + static_assert (полный батч A=33); commit_batch всегда ставит notify; oversized-строка дропает событие (кэш не затирается); max_queue default 40. - tests/aircon: test_tables (составы/атрибуты/дубли/template_detect/ swings-integer), test_convert (default/linear/custom/диапазоны/насыщение в обе стороны/UB-экстремумы), aircon_runner + test_aircon_mock (маппинг+конверсии+batch=1-notify с ожиданием async REG; SET+кэш+ RO/не-шаблон отказы+клэмпинг raw 450 на модуле; C-API smoke). - Прибор AP-WC1E: полный батч шаблона A одним notify — 28 int/bool свойств (DisplayTemperature 7000→200=20.0°C, DeviceCapabilities=5119), boolean SET (JSON true) принят, RO/клэмп/NOTMPL отказы корректны. - CI: 10/10 ×3 стабильно (3 полных прогона); ESP-IDF esp32 build complete. Ревью независимым агентом: 3 круга — B1 kMaxQueue<батча, B2 linear div/0, B3 c_api контракт -1, B4 batch-строки, BL1 INT64_MIN negation UB, BL2 DisplayTemperature экстремумы, flake aircon_mock — всё закрыто; APPROVED (условие круга 3: фикс + зелёный CI ×3). --- CMakeLists.txt | 6 + docs/PLAN_CORE_LIBRARY.md | 2 +- include/fgl-aircon/c_api.h | 166 +++++++++++++++ include/fgl-aircon/session.hpp | 63 ++++++ include/fgl-aircon/templates.hpp | 58 +++++ include/fgl-aircon/types.hpp | 167 +++++++++++++++ src/aircon/c_api.cpp | 276 ++++++++++++++++++++++++ src/aircon/session.cpp | 326 ++++++++++++++++++++++++++++ src/aircon/tables.cpp | 354 +++++++++++++++++++++++++++++++ src/ayla/session.cpp | 85 ++++++-- src/ayla/session.hpp | 5 +- tests/CMakeLists.txt | 15 ++ tests/aircon/aircon_runner.cpp | 171 +++++++++++++++ tests/aircon/test_aircon_mock.py | 111 ++++++++++ tests/aircon/test_convert.cpp | 189 +++++++++++++++++ tests/aircon/test_tables.cpp | 133 ++++++++++++ 16 files changed, 2107 insertions(+), 20 deletions(-) create mode 100644 include/fgl-aircon/c_api.h create mode 100644 include/fgl-aircon/session.hpp create mode 100644 include/fgl-aircon/templates.hpp create mode 100644 include/fgl-aircon/types.hpp create mode 100644 src/aircon/c_api.cpp create mode 100644 src/aircon/session.cpp create mode 100644 src/aircon/tables.cpp create mode 100644 tests/aircon/aircon_runner.cpp create mode 100644 tests/aircon/test_aircon_mock.py create mode 100644 tests/aircon/test_convert.cpp create mode 100644 tests/aircon/test_tables.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f032a97..d921175 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,9 @@ if(ESP_PLATFORM) "src/ayla/httpd.cpp" "src/ayla/log.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" INCLUDE_DIRS "include" @@ -81,6 +84,9 @@ else() src/ayla/httpd.cpp src/ayla/log.cpp src/ayla/session.cpp + src/aircon/c_api.cpp + src/aircon/session.cpp + src/aircon/tables.cpp src/ayla/platform/posix/platform.cpp ) target_include_directories(fgl-aircon diff --git a/docs/PLAN_CORE_LIBRARY.md b/docs/PLAN_CORE_LIBRARY.md index 8a5d854..5eea63a 100644 --- a/docs/PLAN_CORE_LIBRARY.md +++ b/docs/PLAN_CORE_LIBRARY.md @@ -296,7 +296,7 @@ HA-превью (PLAN_HOME_ASSISTANT §4) и тестами. | M0 ✅ | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 | | 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) | -| 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 готов | | M5 | (Опция) `FglHub` N устройств; mDNS-резолвер как опция host-разрешения | Два устройства одновременно | diff --git a/include/fgl-aircon/c_api.h b/include/fgl-aircon/c_api.h new file mode 100644 index 0000000..03e577e --- /dev/null +++ b/include/fgl-aircon/c_api.h @@ -0,0 +1,166 @@ +// C-API (extern "C") fgl-aircon — для cffi/pyfglair (Home Assistant). +// C++-пользователям — fgl-aircon/*.hpp. +#pragma once + +#include +#include + +#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 diff --git a/include/fgl-aircon/session.hpp b/include/fgl-aircon/session.hpp new file mode 100644 index 0000000..bb108ec --- /dev/null +++ b/include/fgl-aircon/session.hpp @@ -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 diff --git a/include/fgl-aircon/templates.hpp b/include/fgl-aircon/templates.hpp new file mode 100644 index 0000000..6646e0b --- /dev/null +++ b/include/fgl-aircon/templates.hpp @@ -0,0 +1,58 @@ +// Интроспекция шаблонов и конверсии (для HA-превью, ESPHome, CLI, тестов). +#pragma once + +#include +#include + +#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 diff --git a/include/fgl-aircon/types.hpp b/include/fgl-aircon/types.hpp new file mode 100644 index 0000000..e68cb7a --- /dev/null +++ b/include/fgl-aircon/types.hpp @@ -0,0 +1,167 @@ +// Публичные типы fgl-aircon (уровень aircon: свойства, конверсии, конфиг). +// Протокольные детали скрыты в src/ayla; этот заголовок — стабильный API +// для интеграций (ESPHome, HA/pyfglair, CLI). +#pragma once + +#include +#include + +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 diff --git a/src/aircon/c_api.cpp b/src/aircon/c_api.cpp new file mode 100644 index 0000000..0d37fa3 --- /dev/null +++ b/src/aircon/c_api.cpp @@ -0,0 +1,276 @@ +// Реализация extern "C" шейма (cffi/pyfglair). Значения enum'ов C-API +// обязаны совпадать с C++ enum'ами — проверено static_assert'ами. +#include "fgl-aircon/c_api.h" + +#include +#include +#include + +#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(State::kIdle)); +static_assert(FGL_STATE_REGISTERING == static_cast(State::kRegistering)); +static_assert(FGL_STATE_ONLINE == static_cast(State::kOnline)); +static_assert(FGL_STATE_RECOVERING == static_cast(State::kRecovering)); +static_assert(FGL_STATE_OFFLINE == static_cast(State::kOffline)); +static_assert(FGL_STATE_KEY_ERROR == static_cast(State::kKeyError)); +static_assert(FGL_ERR_NONE == static_cast(Error::kNone)); +static_assert(FGL_ERR_NO_SLOT == static_cast(Error::kNoSlot)); +static_assert(FGL_ERR_UNREACHABLE == static_cast(Error::kUnreachable)); +static_assert(FGL_ERR_KEY_MISMATCH == static_cast(Error::kKeyMismatch)); +static_assert(FGL_ERR_BAD_KEY_EXCHANGE == static_cast(Error::kBadKeyExchange)); +static_assert(FGL_ERR_ACTIVATION_TIMEOUT == static_cast(Error::kActivationTimeout)); +static_assert(FGL_ERR_DECRYPT_FAILED == static_cast(Error::kDecryptFailed)); +static_assert(FGL_PROP_OPERATION_MODE == static_cast(Prop::OperationMode)); +static_assert(FGL_PROP_FAN_SPEED == static_cast(Prop::FanSpeed)); +static_assert(FGL_PROP_ADJUST_TEMPERATURE == static_cast(Prop::AdjustTemperature)); +static_assert(FGL_PROP_DISPLAY_TEMPERATURE == static_cast(Prop::DisplayTemperature)); +static_assert(FGL_PROP_ECONOMY_MODE == static_cast(Prop::EconomyMode)); +static_assert(FGL_PROP_ERROR_CODE == static_cast(Prop::ErrorCode)); +static_assert(FGL_PROP_DEVICE_CAPABILITIES == static_cast(Prop::DeviceCapabilities)); +static_assert(FGL_PROP_REFRESH == static_cast(Prop::Refresh)); +static_assert(FGL_PROP_COUNT == static_cast(Prop::kCount)); +static_assert(FGL_TEMPLATE_A == static_cast(Template::kA)); +static_assert(FGL_TEMPLATE_B == static_cast(Template::kB)); +static_assert(FGL_TEMPLATE_F == static_cast(Template::kF)); +static_assert(FGL_VALUE_INT == static_cast(fgl::aircon::ValueKind::kInt)); +static_assert(FGL_VALUE_BOOL == static_cast(fgl::aircon::ValueKind::kBool)); +static_assert(FGL_VALUE_STRING == + static_cast(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(ctx); + if (c->on_state != nullptr) { + c->on_state(c->user, static_cast(st), + static_cast(err)); + } +} + +void on_property_trampoline(void* ctx, + const fgl::aircon::PropertyEvent& ev) { + auto* c = static_cast(ctx); + if (c->on_property == nullptr) return; + fgl_property_event_t out{}; + out.prop = static_cast(ev.prop); + out.cmd_id = ev.cmd_id; + out.status = ev.status; + out.value.kind = static_cast(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