core(M3): aircon-слой — шаблоны A/B/F, конверсии+override, публичный API + C-API

- 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).
This commit is contained in:
2026-09-28 20:16:54 +03:00
parent 17bea87fdf
commit fa1718f405
16 changed files with 2107 additions and 20 deletions
+166
View File
@@ -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
+63
View File
@@ -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
+58
View File
@@ -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
+167
View File
@@ -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