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
+189
View File
@@ -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); // насыщение или корректное значение — но не падение
}