hsms: HSMS-GS multi-session support (E37 §11)

Connection now supports both HSMS-SS (single session — the
constructor's behaviour, unchanged) and HSMS-GS (multi-session).
add_session(device_id) registers additional sessions; each one has
its own NotSelected/Selected state and its own message/selected
handlers.  In GS mode the Select.req carries session_id=device_id;
in SS mode it stays at 0xFFFF (legacy).  Linktest/Separate remain
connection-scope per spec.

Public API additions:
  add_session(device_id)
  set_session_message_handler(device_id, h)
  set_session_selected_handler(device_id, h)
  session_state(device_id) -> State
  is_session_selected(device_id) -> bool
  send_request(device_id, msg, cb)
  send_data(device_id, msg)

Internal refactor: state_/on_message_/on_selected_ folded into a
SessionSlot map keyed by device_id; SS-style getters/setters route
through the primary session.  T7 + linktest are connection-scope —
T7 fires only when no session is selected; linktest runs while at
least one is.

Five wire-level tests:
  - passive: two sessions selected independently via Select.req
    with their own session_id
  - GS Select.req for an unregistered session id is Rejected
    (EntityNotSelected)
  - data routed by session_id; data on a not-selected session is
    Rejected
  - active: two registered sessions both end up selected via
    serialized Select.req per session
  - SS legacy: existing single-session API still works (session_id
    0xFFFF in Select.req)

COMPLIANCE.md §1 updated: HSMS-GS row goes .

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 11:40:38 +02:00
parent 998e81b3d8
commit d0c7fb71b6
5 changed files with 516 additions and 54 deletions
+69 -15
View File
@@ -16,10 +16,17 @@
namespace secsgem::hsms {
// One HSMS session over a connected TCP socket. Drives the connection state
// machine (NOT_SELECTED -> SELECTED), runs the SELECT/LINKTEST/SEPARATE control
// handshakes and the T3/T6/T7/T8 timers, and correlates data replies by system
// bytes. Single-threaded: all work runs on the socket's executor.
// One HSMS session-set over a connected TCP socket. Drives the connection
// state machine (NOT_SELECTED -> SELECTED, per session), runs the
// SELECT/LINKTEST/SEPARATE control handshakes and the T3/T6/T7/T8 timers,
// and correlates data replies by system bytes. Single-threaded: all work
// runs on the socket's executor.
//
// Supports HSMS-SS (single-session, default — created by the constructor)
// and HSMS-GS (multi-session, E37 §11) via add_session(). In GS mode the
// Select.req carries the session's device_id in the header session_id
// field; in SS mode it carries the kControlSessionId = 0xFFFF sentinel
// per the legacy convention.
class Connection : public std::enable_shared_from_this<Connection> {
public:
enum class Mode { Active, Passive };
@@ -35,11 +42,27 @@ class Connection : public std::enable_shared_from_this<Connection> {
Connection(asio::ip::tcp::socket socket, Mode mode, uint16_t device_id, Timers timers);
void set_message_handler(MessageHandler h) { on_message_ = std::move(h); }
void set_selected_handler(SelectedHandler h) { on_selected_ = std::move(h); }
// SS-style setters: operate on the primary (constructor-supplied) session.
void set_message_handler(MessageHandler h);
void set_selected_handler(SelectedHandler h);
void set_closed_handler(ClosedHandler h) { on_closed_ = std::move(h); }
void set_log_handler(LogHandler h) { on_log_ = std::move(h); }
// ---- HSMS-GS (multi-session) ------------------------------------------
// Register an additional session. In Active mode each registered
// session triggers a Select.req on its device_id once the prior
// session reaches SELECTED. In Passive mode the session can be
// selected by a peer's Select.req(session_id=device_id).
void add_session(uint16_t device_id);
void set_session_message_handler(uint16_t device_id, MessageHandler);
void set_session_selected_handler(uint16_t device_id, SelectedHandler);
State session_state(uint16_t device_id) const;
bool is_session_selected(uint16_t device_id) const;
// Multi-session send overloads — frames carry session_id = device_id.
void send_request(uint16_t device_id, secs2::Message msg, ReplyHandler cb);
void send_data(uint16_t device_id, secs2::Message msg);
// Begin the read loop. Active mode also initiates the SELECT handshake;
// Passive mode arms the T7 not-selected timer and waits for Select.req.
void start();
@@ -57,8 +80,9 @@ class Connection : public std::enable_shared_from_this<Connection> {
// Hard close.
void close(const std::string& reason);
State state() const { return state_; }
bool selected() const { return state_ == State::Selected; }
// SS-style state getters: report the primary session's state.
State state() const;
bool selected() const;
// The HSMS header of the primary currently being dispatched by the
// message_handler. Only non-null inside the handler. Used so the
@@ -86,9 +110,11 @@ class Connection : public std::enable_shared_from_this<Connection> {
void write_next();
// --- handshakes & timers ---
void send_select_req();
void send_select_req(); // primary session
void send_select_req(uint16_t device_id); // explicit session (GS)
void send_linktest_req();
void enter_selected();
void enter_selected(); // primary session
void enter_selected(uint16_t device_id); // explicit session (GS)
void arm_t7();
void arm_linktest();
void start_control_transaction(SType expected_response, uint32_t system_bytes,
@@ -105,11 +131,41 @@ class Connection : public std::enable_shared_from_this<Connection> {
asio::steady_timer linktest_timer_; // periodic linktest interval
Mode mode_;
uint16_t device_id_;
uint16_t device_id_; // primary session's device_id
Timers timers_;
State state_ = State::NotSelected;
bool closed_ = false;
// Per-session state. In SS mode this map has a single entry keyed
// by device_id_. In GS mode add_session() inserts more entries.
struct SessionSlot {
uint16_t device_id;
State state = State::NotSelected;
MessageHandler on_message;
SelectedHandler on_selected;
};
std::map<uint16_t, SessionSlot> sessions_;
// Active mode walk-list: device_ids still awaiting their initial
// Select.req when running multi-session. Empty for SS.
std::deque<uint16_t> pending_active_selects_;
bool gs_mode() const { return sessions_.size() > 1; }
SessionSlot* find_session(uint16_t device_id) {
auto it = sessions_.find(device_id);
return it == sessions_.end() ? nullptr : &it->second;
}
const SessionSlot* find_session(uint16_t device_id) const {
auto it = sessions_.find(device_id);
return it == sessions_.end() ? nullptr : &it->second;
}
// Pick which session an inbound Select.req with session_id=`sid` is
// targeting. SS legacy: any session_id (often 0xFFFF) maps to the
// single primary session. GS: must match a registered device_id.
SessionSlot* resolve_select_target(uint16_t sid);
// Pick which session an inbound data frame with session_id=`sid`
// is for. SS: primary. GS: must match a registered device_id.
SessionSlot* resolve_data_target(uint16_t sid);
std::array<uint8_t, kLengthPrefixSize> len_buf_{};
std::vector<uint8_t> payload_;
@@ -138,10 +194,8 @@ class Connection : public std::enable_shared_from_this<Connection> {
};
std::optional<PendingControl> pending_control_;
const Header* current_header_ = nullptr; // set only inside on_message_ dispatch
const Header* current_header_ = nullptr; // set only inside dispatch
MessageHandler on_message_;
SelectedHandler on_selected_;
ClosedHandler on_closed_;
LogHandler on_log_;
};