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
+67 -18
View File
@@ -18,16 +18,26 @@ namespace {
constexpr uint32_t kLoopTickMs = 20;
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 {
uint8_t type; // 1=GET, 2=SET, 3=DELETE
char name[kMaxName];
int64_t value;
char base_type[10];
char str_value[48]; // строковые SET
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) {
static const char kAlpha[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@@ -143,6 +153,8 @@ struct Session::Impl {
strncmp(queue[i].name, cmd.name, kMaxName) == 0) {
if (cmd.type == 2) {
queue[i].value = cmd.value; // замещаем
copy_str(queue[i].str_value, cmd.str_value,
sizeof(queue[i].str_value));
return true;
}
if (cmd.type == 1) return true; // дубликат GET
@@ -165,6 +177,8 @@ struct Session::Impl {
strncmp(batch[i].name, cmd.name, kMaxName) == 0) {
if (cmd.type == 2) {
batch[i].value = cmd.value;
copy_str(batch[i].str_value, cmd.str_value,
sizeof(batch[i].str_value));
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");
if (strcmp(c.base_type, "boolean") == 0) {
w.boolean(c.value != 0);
} else if (strcmp(c.base_type, "string") == 0) {
w.string(c.str_value);
} else {
w.integer(c.value);
}
@@ -522,26 +538,38 @@ void Session::Impl::handle_datapoint(const HttpRequest& req,
snprintf(ev.name, sizeof(ev.name), "%s", name);
int64_t iv = 0;
bool bv = false;
const char* sv = nullptr;
size_t sv_len = 0;
jsmntype_t vt;
if (data_doc.get_int("value", &iv)) {
ev.is_int = true;
ev.int_value = iv;
} else if (data_doc.get_bool("value", &bv)) {
ev.is_bool = true;
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';
}
int cmd_id = -1, status = 0;
parse_query(req.query, &cmd_id, &status);
ev.cmd_id = cmd_id;
ev.status = status;
if (cbs.on_property != nullptr) {
cbs.on_property(cbs.ctx, ev);
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;
parse_query(req.query, &cmd_id, &status);
ev.cmd_id = cmd_id;
ev.status = status;
if (cbs.on_property != nullptr) {
cbs.on_property(cbs.ctx, ev);
}
}
}
}
@@ -717,7 +745,7 @@ Session* Session::create(const SessionConfig& cfg, const SessionCallbacks& cbs)
if (impl == nullptr) return nullptr;
impl->cfg = cfg;
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;
impl->timings.keepalive_ms = impl->cfg.keepalive_ms;
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;
}
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() {
std::lock_guard<std::mutex> lk(impl_->queue_mu);
if (impl_->batch_open) return false;
@@ -869,19 +917,20 @@ bool Session::begin_batch() {
}
bool Session::commit_batch() {
bool ok = true;
{
std::lock_guard<std::mutex> lk(impl_->queue_mu);
if (!impl_->batch_open) return false;
bool ok = true;
for (uint8_t i = 0; i < impl_->batch_len; i++) {
if (!impl_->enqueue_locked(impl_->batch[i])) ok = false;
}
impl_->batch_open = false;
impl_->batch_len = 0;
if (!ok) return false;
}
// notify ставится ВСЕГДА: даже при частичном отказе поставленные команды
// должны уйти немедленно, а не ждать keep-alive.
impl_->want_notify.store(true, std::memory_order_release);
return true;
return ok;
}
bool Session::abort_batch() {