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
+171
View File
@@ -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;
}