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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user