core(M2): машина состояний сессии Ayla LAN + mock-модуль + интеграционные сценарии

- session.{hpp,cpp}: state machine (idle/registering/online/recovering/
  offline/key_error); httpd-обработчики key_exchange (200/426/412, re-key
  прозрачно), commands (одна команда, 206/200, envelope, глобальный seq_no),
  datapoint (unpack -> PropertyEvent / 401+тишина 50с для re-key-восстановления);
  сессионный поток: local_reg POST?dsn/PUT (local_ip_for), keep-alive, backoff
  x1.6->60с, 503->offline/NoSlot, activation-timeout->recovering, delete_session
  с ожиданием выдачи; очередь с coalescing + batch; телеметрия; колбэки из
  двух потоков с задокументированным контрактом; буферы datapoint-пути в Impl.
- platform: local_ip_for (UDP-connect) posix+esp-idf; стек httpd 24576
  (переполнение 16КБ поймано gdb на Release).
- mock_ac.py: мок-модуль, stdlib-only чистый python AES-256 (свёрстан с
  pycryptodome); сценарии: 503, no-poll, rekey-every, stale-gap (эмуляция
  'вернувшегося' приложения), fail-pushes (битая подпись), garbage-pushes
  (обрыв блока), break-outbound (исходящий десинк -> модуль ре-кает на
  local_reg, как probe1-3), push-every, fail-first-ke.
- session_runner + test_session_mock.py: 9 сценариев через ctest, включая
  самосинхронизацию CBC и восстановление после исходящего десинка.
- Прибор AP-WC1E: активация <=1с; re-key семантика ИСПРАВЛЕНА по живым
  тестам: re-key при зазоре local_reg >= ~44-50с (не по возрасту сессии!);
  при честном keep-alive 15с сессия стабильна без re-key; PROTOCOL/LEGACY/
  PLAN обновлены; восстановление = тишина >порога + возврат.
- CI: 7/7 x3 (gcc-Rel, gcc-ASan/UBSan, clang); ESP-IDF esp32 build complete.
Ревью под-агентом: 2 круга (стек httpd, залипание состояний, dangling cfg,
физика десинка) — APPROVED.
This commit is contained in:
2026-09-27 10:53:28 +03:00
parent 345fe19ca7
commit bfdf22ba64
15 changed files with 2005 additions and 26 deletions

126
src/ayla/session.hpp Normal file
View File

@@ -0,0 +1,126 @@
// Сессия Ayla LAN (сторона «приложения»). docs/PROTOCOL.md §4-6, §4.4.
// Потоки: httpd (входящие от модуля: key exchange/commands/datapoint) и
// session (исходящие local_reg, таймеры, backoff). Крипто-цепочки и выдача
// команд — только в httpd-потоке; session-поток читает очередь под mutex.
#pragma once
#include <atomic>
#include <cstddef>
#include <cstdint>
#include "ayla/crypto.hpp"
#include "ayla/httpd.hpp"
namespace fgl::ayla {
enum class SessionState : uint8_t {
kIdle = 0, // создан, не запущен
kRegistering, // local_reg отправлен, ждём key exchange
kOnline, // сессия активна
kRecovering, // ожидание самолечения (re-key по keep-alive / активация)
kOffline, // модуль недоступен (backoff) или нет слотов
kKeyError, // lanip_key_id не совпал — требуется смена конфига
};
enum class SessionError : int {
kNone = 0,
kNoSlot = 1, // 503: оба слота модуля заняты
kUnreachable = 2, // transport/backoff
kKeyMismatch = 3, // key_id != lanip_key_id (state = kKeyError)
kBadKeyExchange = 4, // ver/proto/sec не поддержаны
kActivationTimeout = 5,// KE прошёл, опроса commands.json нет (>5 c)
kDecryptFailed = 6, // подпись/расшифровка push не сошлись (ждём re-key)
};
// Событие обновления свойства (push модуля). Значение — какой-то один тип.
struct PropertyEvent {
char name[40];
bool is_int = false;
int64_t int_value = 0;
bool is_bool = false;
bool bool_value = false;
char str_value[64]; // используется, если !is_int && !is_bool
int cmd_id = -1; // из ?cmd_id=N (ответ на GET), иначе -1
int status = 0; // из ?status=200
int64_t seq_no = 0;
};
struct SessionConfig {
const char* host = nullptr; // DNS-имя или IP модуля
uint16_t device_port = 80; // порт local_reg модуля
const char* dsn = nullptr; // "AC000W00XXXXXXX"
const char* lanip_key = nullptr; // base64-строка как есть
uint32_t lanip_key_id = 0;
uint16_t listen_port = 10275; // 0 — любой свободный
uint32_t keepalive_ms = 15000;
uint8_t max_queue = 16; // лимит очереди команд
};
struct SessionCallbacks {
// КОНТРАКТ: колбэки приходят из потоков ядра (httpd и/или session),
// возможно перекрытие во времени; обязаны быть быстрыми и реентерабельными.
// Вызывать stop() из колбэка запрещено (deadlock на join).
void (*on_state)(void* ctx, SessionState st, SessionError err);
void (*on_property)(void* ctx, const PropertyEvent& ev);
void* ctx = nullptr;
};
// Тайминги поведения (PROTOCOL §4.3-4.4; проверено на приборе).
struct SessionTimings {
uint32_t keepalive_ms = 15000; // период local_reg
uint32_t activation_timeout_ms = 5000; // нет poll после KE
uint32_t recovering_quiet_ms = 50000; // пауза при десинке/«KE без poll»
// (> порога возврата модуля ~44-50с)
uint32_t no_slot_retry_ms = 60000; // повтор после 503
uint32_t backoff_base_ms = 1000; // transport backoff, шаг x1.6
uint32_t backoff_max_ms = 60000;
uint8_t backoff_attempts = 6;
uint32_t delete_wait_ms = 2000;
};
class Session {
public:
static Session* create(const SessionConfig& cfg, const SessionCallbacks& cbs);
~Session();
Session(const Session&) = delete;
Session& operator=(const Session&) = delete;
bool start();
// Штатное завершение: DELETE-команда + local_reg notify, ожидание выдачи,
// остановка потоков. state -> kIdle.
void stop();
SessionState state() const;
SessionError last_error() const;
uint16_t listen_port() const;
// Телеметрия (диагностика).
uint32_t rekey_count() const;
uint32_t pushes_ok() const;
uint32_t pushes_bad() const;
uint32_t commands_served() const;
bool had_activity() const;
// ---- Команды (потокобезопасны; кладутся в очередь с coalescing) ----
// GET-команда: свойство придёт on_property (cmd_id совпадает).
bool get_property(const char* name);
// SET-команда (integer/boolean как int64).
bool set_property(const char* name, int64_t value,
const char* base_type = "integer");
// Пакет: собрать несколько команд, один notify на commit.
bool begin_batch();
bool commit_batch();
bool abort_batch();
// DELETE local_reg.json/delete_session (для stop() и ручного завершения).
bool delete_session();
// Тест-хук: тайминги. ТОЛЬКО до start() (после — читаются потоками ядра).
void set_timings_for_test(const SessionTimings& t);
private:
Session(const SessionConfig& cfg, const SessionCallbacks& cbs);
struct Impl;
Impl* impl_;
};
} // namespace fgl::ayla