core(M4): fglctl, fglair-discover, README библиотеки; M5 — вне скоупа core-релиза

- README.md (корень): сборка POSIX/ESP-IDF-компонентом, тесты, получение
  ключа (fglair-discover), примеры fglctl; planned-компоненты помечены.
- examples/cli/fglctl.cpp: monitor/get/set/status на публичном API
  (batch GET шаблона, конверсии, wait_online, логи в stderr, exit-коды;
  сборка с -Wall -Wextra -Werror).
- tools/fglair-discover: облачный provisioning (eu/us/cn, endpoints и
  app_id/secret из PROTOCOL §7), --format json|esphome-secrets, --out 0600,
  обработка URLError/KeyError; stdlib-only.
- tests/tools/test_fglair_discover.py: мок-облако (http.server), 4 теста,
  ctest tools_fglair_discover.
- docs/reports/M4_SOAK.md: 60-мин soak на AP-WC1E — 0 рассинхронов,
  33/33 свойства (вкл. строковые), 273 опроса модуля, pushes_bad=0;
  24-ч прогон — приёмочный шаг перед релизом.
- План: M4 ✅; M5 (FglHub/mDNS) — ВНЕ СКОУПА core-релиза.
- Гигиена: config_kata.json (корень+docs/legacy) в .gitignore; MQTT-креды
  из docs/legacy/run.sh заменены плейсхолдером (история чистится отдельно).
- Фиксы ревью: mock арм fail-pushes после активации (флейк san-сборки);
  fglctl: валидация strtoll (abc/99 — отказ, exit!=0), чтение config с
  предупреждением об обрезке, Config{} вместо memset; discover: 0600.
CI: 11/11 ×3 (gcc-Rel, gcc-ASan/UBSan, clang); ESP-IDF esp32 build complete.
This commit is contained in:
2026-09-29 01:16:58 +03:00
parent fa1718f405
commit 31cd56f082
12 changed files with 780 additions and 4 deletions
+256
View File
@@ -0,0 +1,256 @@
// fglctl — CLI для fglair-core (пример использования публичного API).
//
// fglctl <config.json> monitor [сек] — события/состояния/статистика
// fglctl <config.json> get <PropEnum...> — прочитать свойства
// fglctl <config.json> set <PropEnum> <знач> — записать свойство
// fglctl <config.json> status — краткий статус и выход
//
// config.json — формат tools/fglair-discover / config_*.json:
// {"ip_address": "...", "dsn": "...", "lanip_key": "...",
// "lanip_key_id": N, "model": "AP-WC1E", ...}
#include <cerrno>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <thread>
#include "fgl-aircon/session.hpp"
#include "fgl-aircon/templates.hpp"
#include "ayla/json.hpp"
using fgl::aircon::Prop;
using fgl::aircon::Session;
using fgl::aircon::State;
using fgl::aircon::Template;
using fgl::aircon::Value;
namespace {
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 "?";
}
void log_sink(int level, const char* msg, size_t len, void*) {
const char* tag = level == 0 ? "D" : level == 1 ? "I" : level == 2 ? "W" : "E";
fprintf(stderr, "[%s] %.*s\n", tag, static_cast<int>(len), msg);
}
void on_state(void* /*ctx*/, State st, fgl::aircon::Error err) {
printf("STATE %s err=%d\n", state_name(st), static_cast<int>(err));
fflush(stdout);
}
void on_property(void* /*ctx*/, 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 = \"%s\"", n, ev.value.s);
} else {
printf("PROP %s = %lld", n, static_cast<long long>(ev.value.i));
if (ev.value.kind == fgl::aircon::ValueKind::kBool) printf(" (bool)");
}
if (ev.cmd_id >= 0) printf(" [cmd_id=%d status=%d]", ev.cmd_id, ev.status);
printf("\n");
fflush(stdout);
}
bool load_config(const char* path, fgl::aircon::Config* cfg,
Template* tmpl) {
FILE* f = fopen(path, "rb");
if (f == nullptr) {
fprintf(stderr, "Не открыть %s\n", path);
return false;
}
static char buf[4096];
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
bool truncated = !feof(f);
fclose(f);
buf[n] = '\0';
if (truncated) {
fprintf(stderr, "ВНИМАНИЕ: config обрезан (> %zu байт)\n",
sizeof(buf) - 1);
}
fgl::ayla::json::Doc doc;
if (!doc.parse(buf)) {
fprintf(stderr, "config не разобран (ожидался JSON-объект)\n");
return false;
}
static char host[64], dsn[48], key[64], model[24];
if (!doc.get_string("ip_address", host, sizeof(host)) ||
!doc.get_string("dsn", dsn, sizeof(dsn)) ||
!doc.get_string("lanip_key", key, sizeof(key))) {
fprintf(stderr,
"config обязан содержать ip_address, dsn, lanip_key, lanip_key_id\n");
return false;
}
int64_t key_id = 0;
if (!doc.get_int("lanip_key_id", &key_id)) {
fprintf(stderr, "config: нет lanip_key_id\n");
return false;
}
*cfg = fgl::aircon::Config{};
cfg->device_port = 80;
cfg->listen_port = 10275;
cfg->keepalive_ms = 15000;
cfg->max_queue = 40;
cfg->host = host;
cfg->dsn = dsn;
cfg->lanip_key = key;
cfg->lanip_key_id = static_cast<uint32_t>(key_id);
// Модель опциональна; неизвестные → A (PROTOCOL §8.1, как legacy).
if (doc.get_string("model", model, sizeof(model))) {
*tmpl = fgl::aircon::template_detect(model);
if (!fgl::aircon::template_is_known(model)) {
fprintf(stderr, "ВНИМАНИЕ: модель %s не распознана, используется "
"шаблон A\n", model);
}
} else {
*tmpl = Template::kA;
}
return true;
}
Prop prop_from_name(const char* name) {
for (uint8_t p = 0; p < static_cast<uint8_t>(Prop::kCount); p++) {
if (strcmp(fgl::aircon::prop_enum_name(static_cast<Prop>(p)), name) == 0) {
return static_cast<Prop>(p);
}
}
return Prop::kCount;
}
int wait_online(Session* s, uint32_t timeout_ms) {
auto deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(timeout_ms);
while (std::chrono::steady_clock::now() < deadline) {
if (s->state() == State::kOnline) return 0;
if (s->state() == State::kKeyError) return 2;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
return 1;
}
void print_stats(Session* s) {
printf("STATS state=%s rekeys=%u pushes_ok=%u pushes_bad=%u cmds=%u\n",
state_name(s->state()), s->rekey_count(), s->pushes_ok(),
s->pushes_bad(), s->commands_served());
}
} // namespace
int main(int argc, char** argv) {
if (argc < 3) {
fprintf(stderr,
"usage: %s <config.json> monitor [sec] | get <Prop...> | "
"set <Prop> <value> | status\n"
" свойства: имена enum (OperationMode, FanSpeed, "
"AdjustTemperature, ...)\n",
argv[0]);
return 2;
}
fgl::aircon::set_log_sink(log_sink, nullptr);
fgl::aircon::set_log_level(1);
fgl::aircon::Config cfg{};
Template tmpl = Template::kA;
if (!load_config(argv[1], &cfg, &tmpl)) return 2;
cfg.tmpl = tmpl;
fgl::aircon::Callbacks cbs{};
cbs.on_state = on_state;
cbs.on_property = on_property;
Session* s = Session::create(cfg, cbs);
if (s == nullptr) {
fprintf(stderr, "Session::create failed (конфиг?)\n");
return 2;
}
if (!s->start()) {
fprintf(stderr, "start failed (порт занят?)\n");
delete s;
return 2;
}
const char* cmd = argv[2];
int rc = 0;
if (strcmp(cmd, "monitor") == 0) {
int secs = argc > 3 ? atoi(argv[3]) : 60;
if ((rc = wait_online(s, 15000)) == 0) {
s->batch_begin();
for (auto* info = fgl::aircon::prop_info_begin(tmpl);
info != fgl::aircon::prop_info_begin(tmpl) +
fgl::aircon::prop_info_count(tmpl);
info++) {
s->get_prop(info->prop);
}
s->batch_commit();
auto deadline = std::chrono::steady_clock::now() +
std::chrono::seconds(secs);
while (std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
}
} else if (strcmp(cmd, "get") == 0) {
if ((rc = wait_online(s, 15000)) == 0 && argc > 3) {
s->batch_begin();
for (int i = 3; i < argc; i++) {
Prop p = prop_from_name(argv[i]);
if (p == Prop::kCount) {
fprintf(stderr, "Неизвестное свойство: %s\n", argv[i]);
rc = 2;
} else {
s->get_prop(p);
}
}
s->batch_commit();
std::this_thread::sleep_for(std::chrono::seconds(3));
}
} else if (strcmp(cmd, "set") == 0) {
if (argc < 5) {
fprintf(stderr, "set требует <Prop> <value>\n");
rc = 2;
} else if ((rc = wait_online(s, 15000)) == 0) {
Prop p = prop_from_name(argv[3]);
if (p == Prop::kCount) {
fprintf(stderr, "Неизвестное свойство: %s\n", argv[3]);
rc = 2;
} else {
char* end = nullptr;
errno = 0;
int64_t v = strtoll(argv[4], &end, 10);
if (errno != 0 || end == argv[4] || *end != '\0') {
fprintf(stderr, "Некорректное значение: %s\n", argv[4]);
rc = 2;
} else {
bool ok = s->set_int(p, v);
printf("SET %s %lld -> %s\n", argv[3],
static_cast<long long>(v), ok ? "ok" : "REJECTED");
if (!ok) rc = 3;
std::this_thread::sleep_for(std::chrono::seconds(2));
}
}
}
} else if (strcmp(cmd, "status") == 0) {
rc = wait_online(s, 10000);
Value v{};
if (s->cached(Prop::AdjustTemperature, &v)) {
printf("AdjustTemperature = %lld (0.1°C)\n",
static_cast<long long>(v.i));
}
} else {
fprintf(stderr, "Неизвестная команда: %s\n", cmd);
rc = 2;
}
print_stats(s);
s->stop();
delete s;
return rc;
}