547fd2116b
tests / build-and-test (push) Failing after 43s
Adds S9F1, F3, F5, F7, F9, F11, F13 to the message catalog and wires
the two emission paths that the Connection layer can drive without help
from the Router or the application: S9F7 on a body-decode failure and
S9F9 on a T3 transaction-timer timeout.
Catalog (data/messages.yaml -> generated messages.hpp)
All six MHEAD-carrying messages (F1/F3/F5/F7/F9/F11) use the same
shape — a single <B 10> body with the offending 10-byte HSMS header.
S9F13 (conversation timeout) carries <L,2 <A MEXP> <A EDID>>.
Connection-side emissions (src/hsms/connection.cpp)
emit_s9(function, mhead) New private helper. Builds a 9/function/W=0
data message whose body is <B 10> with the
MHEAD bytes, allocates a fresh sys_bytes,
and queues it onto the write path. No
reply is tracked.
S9F7 on body decode handle_data wraps Message::from_body in a
try/catch. Previously any decode error
closed the connection; now it emits S9F7
with the offending header and continues
reading. Reply-side decode failure also
emits S9F7 and surfaces the new
Error::IllegalData to the waiting
ReplyHandler (rather than making the
caller wait out T3).
S9F9 on T3 timeout The send_request T3 callback rebuilds the
original outgoing MHEAD from
(device_id, expected_stream,
expected_function-1, sys, W=1) and emits
S9F9 before invoking the callback with
Error::Timeout (unchanged).
What's intentionally not yet wired (logged in COMPLIANCE.md)
- S9F3 / S9F5 — "unknown stream / function". These need to live in
the Router's fallback path, which would require either the Router
knowing about a Connection-shaped sender or the Connection's
message wrapper learning which streams the Router has handlers
for. Deferred — today the fallback returns SxF0 only.
- S9F11 — "Data Too Long". Currently we close on oversized frames;
we'd need to also build a synthetic 10-byte MHEAD substitute (the
real header isn't yet available at the point of detection) and
flush it through close_after_flush.
Tests + docs
tests/test_messages.cpp Round-trip every S9F* using a representative
10-byte MHEAD literal; check S9F13 carries
MEXP + EDID. +2 cases / +37 assertions.
COMPLIANCE.md Error Messages row moved from "no S9 stream"
to a detailed status describing what's
emitted vs catalog-only. Coverage matrix
expanded per-message (F1/F7/F9/F13 ✅;
F3/F5/F11 🟡 catalog-only).
Build/demo unaffected: 75 cases / 420 assertions pass; the happy-path
demo never trips a decode error or T3, so the S9 path isn't exercised
end-to-end (but unit tests prove the wire shape).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
151 lines
5.1 KiB
C++
151 lines
5.1 KiB
C++
#pragma once
|
|
|
|
#include <asio.hpp>
|
|
#include <array>
|
|
#include <cstdint>
|
|
#include <deque>
|
|
#include <functional>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <system_error>
|
|
|
|
#include "secsgem/hsms/header.hpp"
|
|
#include "secsgem/secs2/message.hpp"
|
|
|
|
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.
|
|
class Connection : public std::enable_shared_from_this<Connection> {
|
|
public:
|
|
enum class Mode { Active, Passive };
|
|
enum class State { NotSelected, Selected };
|
|
|
|
// Handler for an inbound primary (request) message. Return a reply Message to
|
|
// send it back (system bytes are filled in automatically), or nullopt for none.
|
|
using MessageHandler = std::function<std::optional<secs2::Message>(const secs2::Message&)>;
|
|
using SelectedHandler = std::function<void()>;
|
|
using ClosedHandler = std::function<void(const std::string& reason)>;
|
|
using ReplyHandler = std::function<void(std::error_code, const secs2::Message&)>;
|
|
using LogHandler = std::function<void(const std::string&)>;
|
|
|
|
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); }
|
|
void set_closed_handler(ClosedHandler h) { on_closed_ = std::move(h); }
|
|
void set_log_handler(LogHandler h) { on_log_ = std::move(h); }
|
|
|
|
// 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();
|
|
|
|
// Send a primary data message (W-bit set) and invoke `cb` with the reply or a
|
|
// timeout/error. Must be SELECTED.
|
|
void send_request(secs2::Message msg, ReplyHandler cb);
|
|
|
|
// Send a data message with no reply expected.
|
|
void send_data(secs2::Message msg);
|
|
|
|
// Graceful teardown: send Separate.req and close.
|
|
void separate();
|
|
|
|
// Hard close.
|
|
void close(const std::string& reason);
|
|
|
|
State state() const { return state_; }
|
|
bool selected() const { return state_ == State::Selected; }
|
|
|
|
private:
|
|
// --- read path ---
|
|
void read_length();
|
|
void on_length(std::error_code ec, std::size_t n);
|
|
void on_payload(std::error_code ec, std::size_t n);
|
|
void handle_frame(Frame frame);
|
|
void handle_data(const Frame& frame);
|
|
void handle_control(const Frame& frame);
|
|
|
|
// --- write path ---
|
|
void send_frame(Frame frame);
|
|
void write_next();
|
|
|
|
// --- handshakes & timers ---
|
|
void send_select_req();
|
|
void send_linktest_req();
|
|
void enter_selected();
|
|
void arm_t7();
|
|
void arm_linktest();
|
|
void start_control_transaction(SType expected_response, uint32_t system_bytes,
|
|
const char* what);
|
|
void clear_control_transaction();
|
|
|
|
// --- S9 error stream emissions ---
|
|
// Build and send an unsolicited S9F<function> primary with a single
|
|
// <B 10> body carrying the offending message's 10-byte MHEAD. Used for
|
|
// F1/F3/F5/F7/F9/F11 (all of which share that body shape). No reply is
|
|
// tracked — these notifications are typically W=0.
|
|
void emit_s9(uint8_t function, const std::array<uint8_t, 10>& mhead);
|
|
|
|
uint32_t next_system_bytes();
|
|
void log(const std::string& msg);
|
|
|
|
asio::ip::tcp::socket socket_;
|
|
asio::steady_timer t6_timer_; // control transaction timeout
|
|
asio::steady_timer t7_timer_; // not-selected timeout (passive)
|
|
asio::steady_timer t8_timer_; // intercharacter timeout (during a read)
|
|
asio::steady_timer linktest_timer_; // periodic linktest interval
|
|
|
|
Mode mode_;
|
|
uint16_t device_id_;
|
|
Timers timers_;
|
|
State state_ = State::NotSelected;
|
|
bool closed_ = false;
|
|
|
|
std::array<uint8_t, kLengthPrefixSize> len_buf_{};
|
|
std::vector<uint8_t> payload_;
|
|
|
|
std::deque<std::vector<uint8_t>> write_queue_;
|
|
bool writing_ = false;
|
|
bool close_after_flush_ = false;
|
|
std::string close_reason_;
|
|
|
|
uint32_t next_system_bytes_ = 1;
|
|
|
|
// outstanding data request transactions, keyed by system bytes.
|
|
// We track the EXPECTED reply stream/function so we can match exactly
|
|
// instead of inferring "this is a reply" from function parity.
|
|
struct PendingRequest {
|
|
uint8_t expected_stream;
|
|
uint8_t expected_function;
|
|
ReplyHandler cb;
|
|
std::shared_ptr<asio::steady_timer> t3;
|
|
};
|
|
std::map<uint32_t, PendingRequest> pending_requests_;
|
|
|
|
// single outstanding control transaction (select / linktest)
|
|
struct PendingControl {
|
|
SType expected_response;
|
|
uint32_t system_bytes;
|
|
};
|
|
std::optional<PendingControl> pending_control_;
|
|
|
|
MessageHandler on_message_;
|
|
SelectedHandler on_selected_;
|
|
ClosedHandler on_closed_;
|
|
LogHandler on_log_;
|
|
};
|
|
|
|
// Error category for HSMS protocol-level failures surfaced through ReplyHandler.
|
|
enum class Error {
|
|
Timeout = 1,
|
|
Closed = 2,
|
|
IllegalData = 3, // peer's reply body couldn't be decoded; we also send S9F7
|
|
};
|
|
std::error_code make_error(Error e);
|
|
|
|
} // namespace secsgem::hsms
|