Initial commit: C++20 SECS-II / HSMS / GEM client + server

A fully containerised SECS/GEM toolchain. Single docker compose project,
no host build tools. 63 unit-test cases / 278 assertions, two demo
executables, end-to-end two-container demo exercising every implemented
capability.

Architecture (bottom-up):

  secs2/   E5 SECS-II codec
    Item        variant over L/A/B/BOOLEAN/I1-8/U1-8/F4/F8
    encode/decode  big-endian, 1/2/3-byte length encoding
    Message     SxFy + W-bit + optional root item
    to_sml      human-readable text rendering

  hsms/    E37 HSMS transport (TCP)
    Header      10-byte header + SType enum (Data/Select/Deselect/
                Linktest/Reject/Separate)
    Frame       4-byte length prefix + payload encode/decode
    Connection  async Asio TCP, NOT-SELECTED -> SELECTED state machine,
                T3/T5/T6/T7/T8 timers, system-bytes reply correlation,
                graceful close-after-flush separation

  endpoint  active Client (connect with T5 retry) and passive Server
            (accept loop) wrappers over Connection

  gem/     E30 GEM logic
    ControlStateMachine  5-state E30 control model with operator
                         actions, host requests, SEMI-mandated ack
                         codes (OnlineAck, OfflineAck, CommAck), and
                         a state-change handler
    EquipmentDataModel   in-memory dictionary: SVIDs, DVIDs, ECIDs
                         (with EAC), CEIDs, report defs, CEID->report
                         links, enabled-events set, alarm table
                         (ALCD, enabled, active), process programs,
                         host command registry, clock (16-char
                         YYYYMMDDhhmmsscc with offset)
    messages.hpp         builders + parsers for every SxFy below

GEM message coverage (full list):

  S1F1/F2    Are You There / On Line Data
  S1F3/F4    Selected Equipment Status Request / Data
  S1F11/F12  Status Variable Namelist Request / Data
  S1F13/F14  Establish Communications (+ CommAck)
  S1F15/F16  Request OFFLINE (+ OfflineAck)
  S1F17/F18  Request ONLINE (+ OnlineAck)
  S2F13/F14  Equipment Constant Request / Data
  S2F15/F16  EC Send + EquipmentAck (Accept/UnknownEcid/Busy/OutOfRange)
  S2F17/F18  Date and Time Request / Data
  S2F29/F30  Equipment Constant Namelist Request / Data
  S2F31/F32  Date and Time Set Request / TimeAck
  S2F33/F34  Define Report + DefineReportAck (5 enum values)
  S2F35/F36  Link Event Report + LinkEventAck
  S2F37/F38  Enable / Disable Event Report + EnableEventAck
  S2F41/F42  Host Command + HostCmdAck (7 values) + per-param CPACKs
  S5F1/F2    Alarm Report Send + AlarmAck (ALCD bit-7 set/cleared
             + lower-7 category)
  S5F3/F4    Enable/Disable Alarm Send + AlarmAck
  S5F5/F6    List Alarms Request / Data (active alarms tagged in ALCD)
  S6F11/F12  Event Report Send (equipment-initiated CEID emission
             with full report data) + EventReportAck
  S7F3/F4    Process Program Send + ProcessProgramAck (7 values)
  S7F5/F6    Process Program Request / Data
  S7F19/F20  Current EPPD List Request / Data
  S10F1/F2   Terminal Display Single (host->equipment) + TerminalAck
  S10F3/F4   Terminal Display Single (equipment->host)

Demo apps:

  apps/secs_server.cpp   passive equipment. Populates the data model
                         with 3 SVIDs (ControlState, Clock,
                         EventsEnabled), 2 ECIDs, 3 CEIDs
                         (ControlStateChanged, AlarmSetEvent,
                         ProcessStarted), 2 alarms (Chiller Temp High
                         cat 4, Door Open cat 1), 2 recipes
                         (RECIPE-A, RECIPE-B), and 4 host commands
                         (START, STOP, PAUSE, FAULT). Emits S6F11 on
                         every control state transition + on START;
                         emits S5F1 + the AlarmSetEvent CEID on FAULT.
                         Pushes an S10F3 welcome message when the host
                         comes online.

  apps/secs_client.cpp   active host. Walks 17 steps: Establish ->
                         Online -> S1F11 SVID namelist -> S1F3 read ->
                         S2F29 EC namelist -> S2F13 read ->
                         S2F17 clock -> S2F33/S2F35/S2F37 dynamic
                         event subscription -> S2F41 START
                         (-> receives S6F11) -> S5F5 alarm list ->
                         S5F3 enable alarm 1 -> S2F41 FAULT
                         (-> receives S5F1 + S6F11) -> S7F19/S7F5
                         recipe list + body -> S10F1 terminal ->
                         S1F15 Offline -> Separate. Handles inbound
                         S6F11, S5F1, S10F3 primaries.

Testing:

  tests/test_secs2.cpp         codec round-trip for every format,
                               byte-layout assertions for known values,
                               truncation/trailing-byte rejection,
                               nested list round-trip, SML rendering
  tests/test_hsms.cpp          header byte layout, data + control
                               header round-trip, full frame round-
                               trip with length prefix, short-payload
                               rejection
  tests/test_control_state.cpp every (state, event) pair in the E30
                               control state machine, including
                               AlreadyOnline / NotAccept rejections
                               and idempotent offline-while-offline
  tests/test_data_model.cpp    SVID/ECID/Alarm/Recipe CRUD, clock
                               format + parse, host command registry,
                               full event-report pipeline (define ->
                               link -> enable -> compose) with
                               every error path (InvalidVid,
                               UnknownCeid, UnknownRptid), alarm
                               set/clear with ALCD bit-7 semantics
  tests/test_messages.cpp      round-trip + byte-layout for every
                               builder/parser pair, including S6F11
                               event reports with mixed item types

Toolchain:

  Dockerfile          Ubuntu 24.04, g++-13, CMake, Ninja, libasio-dev
  docker-compose.yml  builder / tests / server / client services,
                      source bind-mounted, build artifacts in a
                      named volume so the host tree stays clean
  CMakeLists.txt      C++20, -Wall -Wextra -Wpedantic, standalone
                      Asio (ASIO_STANDALONE), doctest via FetchContent

Documentation:

  README.md           architecture, quick start, demo log
  COMPLIANCE.md       honest per-capability E5/E30/E37 audit with
                      spec section refs. Calls out what's implemented,
                      what's partial (Reject.req, Alarms missing F7/F8,
                      EC range validation, PP without verify, terminal
                      single-line only), and what's intentionally not
                      yet implemented (spooling, S9 error stream,
                      Documentation S1F19/F20+F21/F22, limits monitoring,
                      trace data collection, multi-block, material
                      movement). Does NOT claim "100% GEM-compliant" and
                      lists the work required to honestly make that claim.

This is Layer 0 + the start of Layer 1 from implementation_plan.md.
The transition-table-driven "spec-as-data" architecture (Layer 1
proper) is not yet implemented; the current code uses imperative
state machines that are structurally ready to be refactored onto
tables.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 00:21:10 +02:00
commit 96b02f8b50
36 changed files with 5210 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include <asio.hpp>
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include "secsgem/hsms/connection.hpp"
#include "secsgem/hsms/types.hpp"
namespace secsgem {
using hsms::Connection;
using hsms::Timers;
// Passive (equipment) endpoint: listens for inbound TCP connections and turns
// each into a passive HSMS Connection. The connection handler is invoked once
// per accepted session so the application can install message/selected handlers
// before the session starts.
class Server {
public:
struct Config {
uint16_t port = 5000;
uint16_t device_id = 0;
Timers timers{};
};
using ConnectionHandler = std::function<void(std::shared_ptr<Connection>)>;
using LogHandler = std::function<void(const std::string&)>;
Server(asio::io_context& io, Config cfg);
void on_connection(ConnectionHandler h) { on_connection_ = std::move(h); }
void on_log(LogHandler h) { on_log_ = std::move(h); }
void start();
private:
void do_accept();
void log(const std::string& msg);
asio::io_context& io_;
asio::ip::tcp::acceptor acceptor_;
Config cfg_;
ConnectionHandler on_connection_;
LogHandler on_log_;
};
// Active (host) endpoint: connects out to a passive peer, retrying every T5 on
// failure, and turns the socket into an active HSMS Connection (which initiates
// SELECT). The connection handler is invoked once the TCP connection is up.
class Client {
public:
struct Config {
std::string host = "127.0.0.1";
uint16_t port = 5000;
uint16_t device_id = 0;
Timers timers{};
};
using ConnectionHandler = std::function<void(std::shared_ptr<Connection>)>;
using LogHandler = std::function<void(const std::string&)>;
Client(asio::io_context& io, Config cfg);
void on_connection(ConnectionHandler h) { on_connection_ = std::move(h); }
void on_log(LogHandler h) { on_log_ = std::move(h); }
void start();
private:
void do_connect();
void schedule_retry();
void log(const std::string& msg);
asio::io_context& io_;
asio::ip::tcp::resolver resolver_;
asio::steady_timer retry_timer_;
Config cfg_;
ConnectionHandler on_connection_;
LogHandler on_log_;
};
} // namespace secsgem
+101
View File
@@ -0,0 +1,101 @@
#pragma once
#include <cstdint>
#include <functional>
#include <string>
namespace secsgem::gem {
// E30 Control State Model (§6.2). Drives whether the equipment is
// communicating with a host and, if so, who is in control.
enum class ControlState {
EquipmentOffline, // equipment is offline; no host comms attempted
AttemptOnline, // transient: equipment trying to come online
HostOffline, // HSMS up but host has not established control
OnlineLocal, // online, operator in control; host observes only
OnlineRemote, // online, host in control
};
const char* control_state_name(ControlState s);
bool is_online(ControlState s);
// What triggered a state change — surfaced to the on_change handler so the UI
// or logs can show *why* the state moved.
enum class ControlEvent {
OperatorSwitchOnline,
OperatorSwitchOffline,
OperatorSwitchLocal,
OperatorSwitchRemote,
AttemptComplete,
AttemptFailed,
HostRequestOnline, // S1F17
HostRequestOffline, // S1F15
};
const char* control_event_name(ControlEvent e);
// S1F18 ONLACK codes.
enum class OnlineAck : uint8_t {
Accept = 0,
NotAccept = 1,
AlreadyOnline = 2,
};
// S1F16 OFLACK codes.
enum class OfflineAck : uint8_t {
Accept = 0,
};
// S1F14 COMMACK codes.
enum class CommAck : uint8_t {
Accept = 0,
Denied = 1,
};
// Drives the E30 control state. Pure state machine — no IO. The Server layer
// owns one of these per equipment and dispatches host-initiated events and
// operator actions into it.
class ControlStateMachine {
public:
struct Config {
ControlState initial = ControlState::HostOffline;
// When ATTEMPT_ONLINE completes via a host request, do we land in REMOTE
// (host in control) or LOCAL (host observes only)? For host-initiated
// online this defaults to REMOTE; for operator-initiated online it follows
// `operator_default_remote`.
bool host_request_grants_remote = true;
bool operator_default_remote = false;
};
using StateChangeHandler =
std::function<void(ControlState from, ControlState to, ControlEvent trigger)>;
ControlStateMachine();
explicit ControlStateMachine(Config cfg);
ControlState state() const { return state_; }
bool online() const { return is_online(state_); }
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
// Operator actions. Each returns true if a transition occurred, false if the
// current state didn't permit it.
bool operator_online();
bool operator_offline();
bool operator_local();
bool operator_remote();
// Host-initiated requests. The SM responds with the SEMI-mandated ack code
// and performs any transition.
OnlineAck on_host_request_online();
OfflineAck on_host_request_offline();
private:
void transition(ControlState next, ControlEvent trigger);
Config cfg_;
ControlState state_;
StateChangeHandler on_change_;
};
} // namespace secsgem::gem
+264
View File
@@ -0,0 +1,264 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <functional>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include "secsgem/secs2/item.hpp"
namespace secsgem::gem {
namespace s2 = secsgem::secs2;
// ---- Status / data / equipment-constant variables ------------------------
struct StatusVariable {
uint32_t id;
std::string name;
std::string units;
s2::Item value;
};
struct DataVariable {
uint32_t id;
std::string name;
std::string units;
s2::Item value;
};
struct EquipmentConstant {
uint32_t id;
std::string name;
std::string units;
s2::Item value;
s2::Item def_value;
std::string min_str; // optional, ASCII for S2F30 ECMIN/ECMAX
std::string max_str;
};
// ---- Event reports -------------------------------------------------------
struct CollectionEvent {
uint32_t id;
std::string name;
};
struct Report {
uint32_t id;
std::vector<uint32_t> vids;
};
// One report's worth of data at emission time: the RPTID and the values for
// each VID, in declaration order.
struct ReportData {
uint32_t rptid;
std::vector<s2::Item> values;
};
// ---- Alarms --------------------------------------------------------------
struct Alarm {
uint32_t id;
std::string text;
// Lower 7 bits of ALCD: alarm severity category (1=personal safety,
// 2=equipment safety, 4=parameter control error, ...). Bit 7 is the
// set/cleared flag, applied at emit time.
uint8_t severity_category;
};
// ---- Ack codes for the new SxFy ------------------------------------------
// S2F30 carries no ack; S2F34 / S2F36 / S2F38 do.
enum class DefineReportAck : uint8_t {
Accept = 0,
InsufficientSpace = 1,
InvalidFormat = 2,
RptidAlreadyDefined = 3,
InvalidVid = 5,
};
enum class LinkEventAck : uint8_t {
Accept = 0,
InsufficientSpace = 1,
InvalidFormat = 2,
UnknownCeid = 3,
UnknownRptid = 4,
CeidAlreadyLinked = 5,
};
enum class EnableEventAck : uint8_t {
Accept = 0,
UnknownCeid = 1,
};
// S5F2 / S5F4 alarm ack.
enum class AlarmAck : uint8_t {
Accept = 0,
Error = 1,
};
// S6F12 event-report ack.
enum class EventReportAck : uint8_t {
Accept = 0,
Denied = 1,
};
// S7F4 process-program ack.
enum class ProcessProgramAck : uint8_t {
Accept = 0,
PermissionNotGranted = 1,
LengthError = 2,
MatrixOverflow = 3,
PpidNotFound = 4,
ModeUnsupported = 5,
PerformanceError = 6,
};
// ---- Existing ack enums from earlier batches -----------------------------
enum class EquipmentAck : uint8_t {
Accept = 0,
Denied_UnknownEcid = 1,
Denied_Busy = 3,
Denied_OutOfRange = 4,
};
enum class TimeAck : uint8_t {
Accept = 0,
Error = 1,
NotDoneNotEmpty = 2,
};
enum class HostCmdAck : uint8_t {
Accept = 0,
InvalidCommand = 1,
CannotDoNow = 2,
ParameterInvalid = 3,
AcceptedWillFinishLater = 4,
Rejected = 5,
InvalidObject = 6,
};
enum class TerminalAck : uint8_t {
Accepted = 0,
WillNotDisplay = 1,
TerminalNotAvailable = 2,
};
// The in-memory equipment data dictionary. Owns SVIDs, DVIDs, ECIDs, the
// dynamic event-report subscription state (reports + links + enabled set),
// the alarm table, and a small process-program registry. Single-threaded;
// all access happens on the Asio executor.
class EquipmentDataModel {
public:
struct CommandParam {
std::string name;
s2::Item value;
};
using HostCommandHandler = std::function<HostCmdAck(const std::vector<CommandParam>&)>;
// --- SVID ---------------------------------------------------------------
void add_status_variable(StatusVariable sv);
std::optional<StatusVariable> status_variable(uint32_t id) const;
std::vector<StatusVariable> all_status_variables() const;
void set_status_value(uint32_t id, s2::Item value);
// --- DVID ---------------------------------------------------------------
void add_data_variable(DataVariable dv);
std::optional<DataVariable> data_variable(uint32_t id) const;
std::vector<DataVariable> all_data_variables() const;
void set_data_value(uint32_t id, s2::Item value);
// VID lookup that searches SVIDs then DVIDs (E30 §6.5 — VIDs share a
// namespace from the host's point of view).
std::optional<s2::Item> vid_value(uint32_t vid) const;
bool vid_exists(uint32_t vid) const;
// --- ECID ---------------------------------------------------------------
void add_equipment_constant(EquipmentConstant ec);
std::optional<EquipmentConstant> equipment_constant(uint32_t id) const;
std::vector<EquipmentConstant> all_equipment_constants() const;
EquipmentAck set_equipment_constant_value(uint32_t id, s2::Item value);
// --- Clock --------------------------------------------------------------
std::string current_time_string() const;
TimeAck set_time_string(const std::string& time_str);
// --- Host commands ------------------------------------------------------
void register_command(const std::string& rcmd, HostCommandHandler handler);
HostCmdAck dispatch_command(const std::string& rcmd,
const std::vector<CommandParam>& params) const;
bool has_command(const std::string& rcmd) const;
// --- Collection events --------------------------------------------------
void register_event(CollectionEvent ce);
bool has_event(uint32_t ceid) const;
std::vector<CollectionEvent> all_events() const;
// S2F33: define reports. `reports` maps RPTID -> VID list. An empty
// vector deletes all reports (and clears all links). An empty VID list
// for a given RPTID deletes that report.
DefineReportAck define_reports(
const std::vector<std::pair<uint32_t, std::vector<uint32_t>>>& reports);
// S2F35: link CEIDs to RPTID lists. `links` maps CEID -> RPTID list.
// An empty RPTID list for a CEID clears its links. An empty outer list
// clears all links.
LinkEventAck link_event_reports(
const std::vector<std::pair<uint32_t, std::vector<uint32_t>>>& links);
// S2F37: enable/disable events. Empty CEID list applies to *all*
// registered events.
EnableEventAck enable_events(bool enable, const std::vector<uint32_t>& ceids);
bool is_event_enabled(uint32_t ceid) const;
// Compose the report data for a CEID emission (S6F11 body's report list).
std::vector<ReportData> compose_reports_for(uint32_t ceid) const;
std::vector<Report> all_reports() const;
// --- Alarms -------------------------------------------------------------
void add_alarm(Alarm a);
std::optional<Alarm> alarm(uint32_t alid) const;
std::vector<Alarm> all_alarms() const;
AlarmAck set_alarm_enabled(uint32_t alid, bool enable);
bool alarm_enabled(uint32_t alid) const;
// Returns the ALCD byte to put on the wire (bit 7 indicates set/cleared).
// Returns nullopt for unknown alarms.
std::optional<uint8_t> alarm_set(uint32_t alid);
std::optional<uint8_t> alarm_clear(uint32_t alid);
bool alarm_active(uint32_t alid) const;
// --- Process programs ---------------------------------------------------
void add_process_program(std::string ppid, std::string ppbody);
std::optional<std::string> process_program(const std::string& ppid) const;
std::vector<std::string> process_program_list() const;
ProcessProgramAck delete_process_program(const std::string& ppid);
private:
std::map<uint32_t, StatusVariable> svids_;
std::map<uint32_t, DataVariable> dvids_;
std::map<uint32_t, EquipmentConstant> ecids_;
std::int64_t time_offset_seconds_ = 0;
std::map<std::string, HostCommandHandler> commands_;
std::map<uint32_t, CollectionEvent> ceids_;
std::map<uint32_t, Report> reports_;
std::map<uint32_t, std::vector<uint32_t>> ce_links_; // CEID -> RPTID list
std::set<uint32_t> events_enabled_;
std::map<uint32_t, Alarm> alarms_;
std::set<uint32_t> alarms_enabled_;
std::set<uint32_t> alarms_active_;
std::map<std::string, std::string> process_programs_;
};
} // namespace secsgem::gem
+760
View File
@@ -0,0 +1,760 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <utility>
#include <variant>
#include <vector>
#include "secsgem/gem/control_state.hpp"
#include "secsgem/gem/data_model.hpp"
#include "secsgem/secs2/item.hpp"
#include "secsgem/secs2/message.hpp"
// GEM (E30 / E5) message builders for the SxFy primaries and replies the demo
// and tests exercise. Builders construct a `secs2::Message`; parsers do the
// inverse and return `std::optional<...>` on shape mismatch.
namespace secsgem::gem {
namespace s2 = secsgem::secs2;
// -------------------------------------------------------------------------
// small Item helpers
// -------------------------------------------------------------------------
inline std::optional<uint32_t> as_u4_scalar(const s2::Item& item) {
if (item.format() != s2::Format::U4) return std::nullopt;
const auto& v = std::get<std::vector<uint32_t>>(item.storage());
if (v.empty()) return std::nullopt;
return v.front();
}
inline std::optional<uint8_t> as_binary_first(const s2::Item& item) {
if (item.format() != s2::Format::Binary) return std::nullopt;
const auto& v = item.as_bytes();
if (v.empty()) return std::nullopt;
return v.front();
}
inline std::optional<std::string> as_ascii(const s2::Item& item) {
if (item.format() != s2::Format::ASCII) return std::nullopt;
return item.as_ascii();
}
inline s2::Item u4_list(const std::vector<uint32_t>& ids) {
return s2::Item::u4(ids); // a single U4 array — SECS-II elements, not a list of items
}
// -------------------------------------------------------------------------
// S1F1 / S1F2 Are You There / On Line Data
// -------------------------------------------------------------------------
inline s2::Message s1f1_are_you_there() { return s2::Message(1, 1, true); }
inline s2::Message s1f2_on_line_data(const std::string& mdln, const std::string& softrev) {
return s2::Message(1, 2, false,
s2::Item::list({s2::Item::ascii(mdln), s2::Item::ascii(softrev)}));
}
// -------------------------------------------------------------------------
// S1F3 / S1F4 Selected Equipment Status Request / Data
//
// S1F3 W: <L,n <U4 SVID> ... > (n=0 means "all SVIDs")
// S1F4 : <L,n <Item> ... > (empty list for unknown SVIDs per E5)
// -------------------------------------------------------------------------
inline s2::Message s1f3_selected_status_request(const std::vector<uint32_t>& svids) {
s2::Item::List children;
children.reserve(svids.size());
for (auto id : svids) children.push_back(s2::Item::u4(id));
return s2::Message(1, 3, true, s2::Item::list(std::move(children)));
}
inline std::optional<std::vector<uint32_t>> parse_s1f3(const s2::Message& m) {
if (!m.body || !m.body->is_list()) return std::nullopt;
std::vector<uint32_t> out;
for (const auto& child : m.body->as_list()) {
auto v = as_u4_scalar(child);
if (!v) return std::nullopt;
out.push_back(*v);
}
return out;
}
inline s2::Message s1f4_selected_status_data(const std::vector<std::optional<s2::Item>>& values) {
s2::Item::List children;
children.reserve(values.size());
for (const auto& v : values) {
children.push_back(v ? *v : s2::Item::list({})); // empty L for unknown
}
return s2::Message(1, 4, false, s2::Item::list(std::move(children)));
}
// -------------------------------------------------------------------------
// S1F11 / S1F12 Status Variable Namelist Request / Data
//
// S1F11 W: <L,n <U4 SVID> ... > (n=0 = all)
// S1F12 : <L,n <L,3 <U4 SVID> <A SVNAME> <A SVUNITS>> ... >
// -------------------------------------------------------------------------
inline s2::Message s1f11_status_namelist_request(const std::vector<uint32_t>& svids) {
s2::Item::List children;
children.reserve(svids.size());
for (auto id : svids) children.push_back(s2::Item::u4(id));
return s2::Message(1, 11, true, s2::Item::list(std::move(children)));
}
inline s2::Message s1f12_status_namelist_data(const std::vector<StatusVariable>& items) {
s2::Item::List children;
children.reserve(items.size());
for (const auto& sv : items) {
children.push_back(s2::Item::list(
{s2::Item::u4(sv.id), s2::Item::ascii(sv.name), s2::Item::ascii(sv.units)}));
}
return s2::Message(1, 12, false, s2::Item::list(std::move(children)));
}
struct StatusName {
uint32_t id;
std::string name;
std::string units;
};
inline std::optional<std::vector<StatusName>> parse_s1f12(const s2::Message& m) {
if (!m.body || !m.body->is_list()) return std::nullopt;
std::vector<StatusName> out;
for (const auto& row : m.body->as_list()) {
if (!row.is_list() || row.as_list().size() != 3) return std::nullopt;
auto id = as_u4_scalar(row.as_list()[0]);
auto name = as_ascii(row.as_list()[1]);
auto units = as_ascii(row.as_list()[2]);
if (!id || !name || !units) return std::nullopt;
out.push_back({*id, *name, *units});
}
return out;
}
// -------------------------------------------------------------------------
// S1F13 / S1F14 Establish Communications
// -------------------------------------------------------------------------
inline s2::Message s1f13_establish_comms(const std::string& mdln, const std::string& softrev) {
return s2::Message(1, 13, true,
s2::Item::list({s2::Item::ascii(mdln), s2::Item::ascii(softrev)}));
}
inline s2::Message s1f14_establish_comms_ack(CommAck ack, const std::string& mdln,
const std::string& softrev) {
return s2::Message(
1, 14, false,
s2::Item::list({s2::Item::binary({static_cast<uint8_t>(ack)}),
s2::Item::list({s2::Item::ascii(mdln), s2::Item::ascii(softrev)})}));
}
// -------------------------------------------------------------------------
// S1F15 / S1F16 Request OFFLINE / OFFLINE Acknowledge
// S1F17 / S1F18 Request ONLINE / ONLINE Acknowledge
// -------------------------------------------------------------------------
inline s2::Message s1f15_request_offline() { return s2::Message(1, 15, true); }
inline s2::Message s1f16_offline_ack(OfflineAck ack) {
return s2::Message(1, 16, false, s2::Item::binary({static_cast<uint8_t>(ack)}));
}
inline s2::Message s1f17_request_online() { return s2::Message(1, 17, true); }
inline s2::Message s1f18_online_ack(OnlineAck ack) {
return s2::Message(1, 18, false, s2::Item::binary({static_cast<uint8_t>(ack)}));
}
// Generic helper for messages whose body is a single <B ack>.
inline std::optional<uint8_t> ack_byte(const s2::Message& msg) {
if (!msg.body) return std::nullopt;
return as_binary_first(*msg.body);
}
// -------------------------------------------------------------------------
// S2F13 / S2F14 Equipment Constant Request / Data
// S2F15 / S2F16 New Equipment Constant Send / Acknowledge
// -------------------------------------------------------------------------
inline s2::Message s2f13_ec_request(const std::vector<uint32_t>& ecids) {
s2::Item::List children;
children.reserve(ecids.size());
for (auto id : ecids) children.push_back(s2::Item::u4(id));
return s2::Message(2, 13, true, s2::Item::list(std::move(children)));
}
inline s2::Message s2f14_ec_data(const std::vector<s2::Item>& values) {
return s2::Message(2, 14, false, s2::Item::list(values));
}
inline s2::Message s2f15_ec_send(
const std::vector<std::pair<uint32_t, s2::Item>>& sets) {
s2::Item::List rows;
rows.reserve(sets.size());
for (const auto& [id, val] : sets) {
rows.push_back(s2::Item::list({s2::Item::u4(id), val}));
}
return s2::Message(2, 15, true, s2::Item::list(std::move(rows)));
}
inline std::optional<std::vector<std::pair<uint32_t, s2::Item>>> parse_s2f15(
const s2::Message& m) {
if (!m.body || !m.body->is_list()) return std::nullopt;
std::vector<std::pair<uint32_t, s2::Item>> out;
for (const auto& row : m.body->as_list()) {
if (!row.is_list() || row.as_list().size() != 2) return std::nullopt;
auto id = as_u4_scalar(row.as_list()[0]);
if (!id) return std::nullopt;
out.emplace_back(*id, row.as_list()[1]);
}
return out;
}
inline s2::Message s2f16_ec_ack(EquipmentAck eac) {
return s2::Message(2, 16, false, s2::Item::binary({static_cast<uint8_t>(eac)}));
}
// -------------------------------------------------------------------------
// S2F17 / S2F18 Date and Time Request / Data
// S2F31 / S2F32 Date and Time Set Request / Acknowledge
// -------------------------------------------------------------------------
inline s2::Message s2f17_date_time_request() { return s2::Message(2, 17, true); }
inline s2::Message s2f18_date_time_data(const std::string& time_str) {
return s2::Message(2, 18, false, s2::Item::ascii(time_str));
}
inline std::optional<std::string> parse_s2f18(const s2::Message& m) {
if (!m.body) return std::nullopt;
return as_ascii(*m.body);
}
inline s2::Message s2f31_date_time_set(const std::string& time_str) {
return s2::Message(2, 31, true, s2::Item::ascii(time_str));
}
inline std::optional<std::string> parse_s2f31(const s2::Message& m) {
if (!m.body) return std::nullopt;
return as_ascii(*m.body);
}
inline s2::Message s2f32_date_time_ack(TimeAck ack) {
return s2::Message(2, 32, false, s2::Item::binary({static_cast<uint8_t>(ack)}));
}
// -------------------------------------------------------------------------
// S2F41 / S2F42 Host Command / Host Command Acknowledge
//
// S2F41 W: <L,2 <A RCMD> <L,n <L,2 <A CPNAME> <Item CPVAL>>>>
// S2F42 : <L,2 <B HCACK> <L,n <L,2 <A CPNAME> <B CPACK>>>>
// -------------------------------------------------------------------------
inline s2::Message s2f41_host_command(
const std::string& rcmd,
const std::vector<EquipmentDataModel::CommandParam>& params = {}) {
s2::Item::List param_rows;
param_rows.reserve(params.size());
for (const auto& p : params) {
param_rows.push_back(s2::Item::list({s2::Item::ascii(p.name), p.value}));
}
return s2::Message(
2, 41, true,
s2::Item::list({s2::Item::ascii(rcmd), s2::Item::list(std::move(param_rows))}));
}
struct HostCommand {
std::string rcmd;
std::vector<EquipmentDataModel::CommandParam> params;
};
inline std::optional<HostCommand> parse_s2f41(const s2::Message& m) {
if (!m.body || !m.body->is_list() || m.body->as_list().size() != 2) return std::nullopt;
const auto& outer = m.body->as_list();
auto rcmd = as_ascii(outer[0]);
if (!rcmd) return std::nullopt;
HostCommand cmd{*rcmd, {}};
if (!outer[1].is_list()) return std::nullopt;
for (const auto& row : outer[1].as_list()) {
if (!row.is_list() || row.as_list().size() != 2) return std::nullopt;
auto name = as_ascii(row.as_list()[0]);
if (!name) return std::nullopt;
cmd.params.push_back({*name, row.as_list()[1]});
}
return cmd;
}
inline s2::Message s2f42_host_command_ack(
HostCmdAck hcack,
const std::vector<std::pair<std::string, uint8_t>>& cpacks = {}) {
s2::Item::List cp_rows;
cp_rows.reserve(cpacks.size());
for (const auto& [name, code] : cpacks) {
cp_rows.push_back(s2::Item::list({s2::Item::ascii(name), s2::Item::binary({code})}));
}
return s2::Message(
2, 42, false,
s2::Item::list({s2::Item::binary({static_cast<uint8_t>(hcack)}),
s2::Item::list(std::move(cp_rows))}));
}
struct HostCommandReply {
HostCmdAck hcack;
std::vector<std::pair<std::string, uint8_t>> cpacks;
};
inline std::optional<HostCommandReply> parse_s2f42(const s2::Message& m) {
if (!m.body || !m.body->is_list() || m.body->as_list().size() != 2) return std::nullopt;
const auto& outer = m.body->as_list();
auto hcack = as_binary_first(outer[0]);
if (!hcack) return std::nullopt;
HostCommandReply rep{static_cast<HostCmdAck>(*hcack), {}};
if (!outer[1].is_list()) return std::nullopt;
for (const auto& row : outer[1].as_list()) {
if (!row.is_list() || row.as_list().size() != 2) return std::nullopt;
auto name = as_ascii(row.as_list()[0]);
auto code = as_binary_first(row.as_list()[1]);
if (!name || !code) return std::nullopt;
rep.cpacks.emplace_back(*name, *code);
}
return rep;
}
// -------------------------------------------------------------------------
// S10F1 / S10F2 Terminal Display, Single (host -> equipment)
// S10F3 / S10F4 Terminal Display, Single (equipment -> host)
// -------------------------------------------------------------------------
inline s2::Message s10f1_terminal_display_single(uint8_t tid, const std::string& text) {
return s2::Message(10, 1, true,
s2::Item::list({s2::Item::binary({tid}), s2::Item::ascii(text)}));
}
struct TerminalDisplay {
uint8_t tid;
std::string text;
};
inline std::optional<TerminalDisplay> parse_terminal_display(const s2::Message& m) {
if (!m.body || !m.body->is_list() || m.body->as_list().size() != 2) return std::nullopt;
const auto& l = m.body->as_list();
auto tid = as_binary_first(l[0]);
auto text = as_ascii(l[1]);
if (!tid || !text) return std::nullopt;
return TerminalDisplay{*tid, *text};
}
inline s2::Message s10f2_terminal_display_ack(TerminalAck ack) {
return s2::Message(10, 2, false, s2::Item::binary({static_cast<uint8_t>(ack)}));
}
inline s2::Message s10f3_terminal_display_single(uint8_t tid, const std::string& text) {
return s2::Message(10, 3, true,
s2::Item::list({s2::Item::binary({tid}), s2::Item::ascii(text)}));
}
inline s2::Message s10f4_terminal_display_ack(TerminalAck ack) {
return s2::Message(10, 4, false, s2::Item::binary({static_cast<uint8_t>(ack)}));
}
// =========================================================================
// Extended GEM SxFy (events, alarms, recipes)
// =========================================================================
// Generic <L,n <U4 id>> body reader, used by several messages.
inline std::optional<std::vector<uint32_t>> parse_u4_list_body(const s2::Message& m) {
if (!m.body || !m.body->is_list()) return std::nullopt;
std::vector<uint32_t> out;
for (const auto& c : m.body->as_list()) {
auto v = as_u4_scalar(c);
if (!v) return std::nullopt;
out.push_back(*v);
}
return out;
}
inline s2::Item u4_list_item(const std::vector<uint32_t>& ids) {
s2::Item::List children;
children.reserve(ids.size());
for (auto id : ids) children.push_back(s2::Item::u4(id));
return s2::Item::list(std::move(children));
}
// -------------------------------------------------------------------------
// S2F29 / S2F30 Equipment Constant Namelist Request / Data
// S2F30: <L,n <L,6 <U4 ECID> <A ECNAME> <A ECMIN> <A ECMAX> <A ECDEF> <A ECUNITS>>>
// -------------------------------------------------------------------------
inline s2::Message s2f29_ec_namelist_request(const std::vector<uint32_t>& ecids) {
return s2::Message(2, 29, true, u4_list_item(ecids));
}
inline s2::Message s2f30_ec_namelist_data(const std::vector<EquipmentConstant>& ecs) {
s2::Item::List rows;
rows.reserve(ecs.size());
for (const auto& ec : ecs) {
rows.push_back(s2::Item::list({
s2::Item::u4(ec.id),
s2::Item::ascii(ec.name),
s2::Item::ascii(ec.min_str),
s2::Item::ascii(ec.max_str),
s2::Item::ascii(""), // ECDEF rendering left simple
s2::Item::ascii(ec.units),
}));
}
return s2::Message(2, 30, false, s2::Item::list(std::move(rows)));
}
// -------------------------------------------------------------------------
// S2F33 / S2F34 Define Report
//
// S2F33 W: <L,2 <U4 DATAID> <L,a <L,2 <U4 RPTID> <L,n <U4 VID>>>>>
// S2F34 : <B DRACK>
// -------------------------------------------------------------------------
inline s2::Message s2f33_define_report(
uint32_t dataid,
const std::vector<std::pair<uint32_t, std::vector<uint32_t>>>& reports) {
s2::Item::List rows;
rows.reserve(reports.size());
for (const auto& [rptid, vids] : reports) {
rows.push_back(s2::Item::list({s2::Item::u4(rptid), u4_list_item(vids)}));
}
return s2::Message(2, 33, true,
s2::Item::list({s2::Item::u4(dataid), s2::Item::list(std::move(rows))}));
}
struct DefineReportRequest {
uint32_t dataid;
std::vector<std::pair<uint32_t, std::vector<uint32_t>>> reports;
};
inline std::optional<DefineReportRequest> parse_s2f33(const s2::Message& m) {
if (!m.body || !m.body->is_list() || m.body->as_list().size() != 2) return std::nullopt;
const auto& outer = m.body->as_list();
auto dataid = as_u4_scalar(outer[0]);
if (!dataid) return std::nullopt;
DefineReportRequest req{*dataid, {}};
if (!outer[1].is_list()) return std::nullopt;
for (const auto& row : outer[1].as_list()) {
if (!row.is_list() || row.as_list().size() != 2) return std::nullopt;
auto rptid = as_u4_scalar(row.as_list()[0]);
if (!rptid) return std::nullopt;
auto vids = std::vector<uint32_t>{};
if (!row.as_list()[1].is_list()) return std::nullopt;
for (const auto& v : row.as_list()[1].as_list()) {
auto u = as_u4_scalar(v);
if (!u) return std::nullopt;
vids.push_back(*u);
}
req.reports.emplace_back(*rptid, std::move(vids));
}
return req;
}
inline s2::Message s2f34_define_report_ack(DefineReportAck ack) {
return s2::Message(2, 34, false, s2::Item::binary({static_cast<uint8_t>(ack)}));
}
// -------------------------------------------------------------------------
// S2F35 / S2F36 Link Event Report
//
// S2F35 W: <L,2 <U4 DATAID> <L,a <L,2 <U4 CEID> <L,n <U4 RPTID>>>>>
// S2F36 : <B LRACK>
// -------------------------------------------------------------------------
inline s2::Message s2f35_link_event_report(
uint32_t dataid,
const std::vector<std::pair<uint32_t, std::vector<uint32_t>>>& links) {
s2::Item::List rows;
rows.reserve(links.size());
for (const auto& [ceid, rpts] : links) {
rows.push_back(s2::Item::list({s2::Item::u4(ceid), u4_list_item(rpts)}));
}
return s2::Message(2, 35, true,
s2::Item::list({s2::Item::u4(dataid), s2::Item::list(std::move(rows))}));
}
struct LinkEventReportRequest {
uint32_t dataid;
std::vector<std::pair<uint32_t, std::vector<uint32_t>>> links;
};
inline std::optional<LinkEventReportRequest> parse_s2f35(const s2::Message& m) {
if (!m.body || !m.body->is_list() || m.body->as_list().size() != 2) return std::nullopt;
const auto& outer = m.body->as_list();
auto dataid = as_u4_scalar(outer[0]);
if (!dataid) return std::nullopt;
LinkEventReportRequest req{*dataid, {}};
if (!outer[1].is_list()) return std::nullopt;
for (const auto& row : outer[1].as_list()) {
if (!row.is_list() || row.as_list().size() != 2) return std::nullopt;
auto ceid = as_u4_scalar(row.as_list()[0]);
if (!ceid) return std::nullopt;
std::vector<uint32_t> rpts;
if (!row.as_list()[1].is_list()) return std::nullopt;
for (const auto& v : row.as_list()[1].as_list()) {
auto u = as_u4_scalar(v);
if (!u) return std::nullopt;
rpts.push_back(*u);
}
req.links.emplace_back(*ceid, std::move(rpts));
}
return req;
}
inline s2::Message s2f36_link_event_report_ack(LinkEventAck ack) {
return s2::Message(2, 36, false, s2::Item::binary({static_cast<uint8_t>(ack)}));
}
// -------------------------------------------------------------------------
// S2F37 / S2F38 Enable / Disable Event Report
//
// S2F37 W: <L,2 <BOOLEAN CEED> <L,n <U4 CEID>>>
// S2F38 : <B ERACK>
// -------------------------------------------------------------------------
inline s2::Message s2f37_enable_event(bool enable, const std::vector<uint32_t>& ceids) {
return s2::Message(2, 37, true,
s2::Item::list({s2::Item::boolean(enable), u4_list_item(ceids)}));
}
struct EnableEventRequest {
bool enable;
std::vector<uint32_t> ceids;
};
inline std::optional<EnableEventRequest> parse_s2f37(const s2::Message& m) {
if (!m.body || !m.body->is_list() || m.body->as_list().size() != 2) return std::nullopt;
const auto& outer = m.body->as_list();
if (outer[0].format() != s2::Format::Boolean) return std::nullopt;
const auto& bytes = outer[0].as_bytes();
if (bytes.empty()) return std::nullopt;
EnableEventRequest req{bytes.front() != 0, {}};
if (!outer[1].is_list()) return std::nullopt;
for (const auto& v : outer[1].as_list()) {
auto u = as_u4_scalar(v);
if (!u) return std::nullopt;
req.ceids.push_back(*u);
}
return req;
}
inline s2::Message s2f38_enable_event_ack(EnableEventAck ack) {
return s2::Message(2, 38, false, s2::Item::binary({static_cast<uint8_t>(ack)}));
}
// -------------------------------------------------------------------------
// S5F1 / S5F2 Alarm Report Send / Ack
//
// S5F1 W: <L,3 <B ALCD> <U4 ALID> <A ALTX>>
// S5F2 : <B ACKC5>
// -------------------------------------------------------------------------
inline s2::Message s5f1_alarm_report(uint8_t alcd, uint32_t alid, const std::string& altx) {
return s2::Message(5, 1, true,
s2::Item::list({s2::Item::binary({alcd}), s2::Item::u4(alid),
s2::Item::ascii(altx)}));
}
struct AlarmReport {
uint8_t alcd;
uint32_t alid;
std::string altx;
bool is_set() const { return (alcd & 0x80) != 0; }
uint8_t category() const { return alcd & 0x7F; }
};
inline std::optional<AlarmReport> parse_s5f1(const s2::Message& m) {
if (!m.body || !m.body->is_list() || m.body->as_list().size() != 3) return std::nullopt;
const auto& l = m.body->as_list();
auto alcd = as_binary_first(l[0]);
auto alid = as_u4_scalar(l[1]);
auto altx = as_ascii(l[2]);
if (!alcd || !alid || !altx) return std::nullopt;
return AlarmReport{*alcd, *alid, *altx};
}
inline s2::Message s5f2_alarm_ack(AlarmAck ack) {
return s2::Message(5, 2, false, s2::Item::binary({static_cast<uint8_t>(ack)}));
}
// -------------------------------------------------------------------------
// S5F3 / S5F4 Enable / Disable Alarm Send
//
// S5F3 W: <L,2 <B ALED> <U4 ALID>> (ALED 0x80 = enable, 0x00 = disable)
// S5F4 : <B ACKC5>
// -------------------------------------------------------------------------
inline constexpr uint8_t kAlarmEnableByte = 0x80;
inline constexpr uint8_t kAlarmDisableByte = 0x00;
inline s2::Message s5f3_enable_alarm(bool enable, uint32_t alid) {
return s2::Message(
5, 3, true,
s2::Item::list({s2::Item::binary({enable ? kAlarmEnableByte : kAlarmDisableByte}),
s2::Item::u4(alid)}));
}
struct EnableAlarmRequest {
bool enable;
uint32_t alid;
};
inline std::optional<EnableAlarmRequest> parse_s5f3(const s2::Message& m) {
if (!m.body || !m.body->is_list() || m.body->as_list().size() != 2) return std::nullopt;
const auto& l = m.body->as_list();
auto aled = as_binary_first(l[0]);
auto alid = as_u4_scalar(l[1]);
if (!aled || !alid) return std::nullopt;
return EnableAlarmRequest{(*aled & 0x80) != 0, *alid};
}
inline s2::Message s5f4_enable_alarm_ack(AlarmAck ack) {
return s2::Message(5, 4, false, s2::Item::binary({static_cast<uint8_t>(ack)}));
}
// -------------------------------------------------------------------------
// S5F5 / S5F6 List Alarms Request / Data
//
// S5F5 W: <L,n <U4 ALID>> (empty = all)
// S5F6 : <L,n <L,3 <B ALCD> <U4 ALID> <A ALTX>>>
// -------------------------------------------------------------------------
inline s2::Message s5f5_list_alarms_request(const std::vector<uint32_t>& alids) {
return s2::Message(5, 5, true, u4_list_item(alids));
}
inline s2::Message s5f6_list_alarms_data(const std::vector<Alarm>& alarms,
const std::function<bool(uint32_t)>& active) {
s2::Item::List rows;
rows.reserve(alarms.size());
for (const auto& a : alarms) {
const uint8_t alcd = (a.severity_category & 0x7F) |
static_cast<uint8_t>(active(a.id) ? 0x80 : 0x00);
rows.push_back(s2::Item::list(
{s2::Item::binary({alcd}), s2::Item::u4(a.id), s2::Item::ascii(a.text)}));
}
return s2::Message(5, 6, false, s2::Item::list(std::move(rows)));
}
// -------------------------------------------------------------------------
// S6F11 / S6F12 Event Report Send
//
// S6F11 W: <L,3 <U4 DATAID> <U4 CEID> <L,a <L,2 <U4 RPTID> <L,n <Item>>>>>
// S6F12 : <B ACKC6>
// -------------------------------------------------------------------------
inline s2::Message s6f11_event_report(uint32_t dataid, uint32_t ceid,
const std::vector<ReportData>& reports) {
s2::Item::List rows;
rows.reserve(reports.size());
for (const auto& r : reports) {
s2::Item::List values(r.values.begin(), r.values.end());
rows.push_back(s2::Item::list({s2::Item::u4(r.rptid), s2::Item::list(std::move(values))}));
}
return s2::Message(6, 11, true,
s2::Item::list({s2::Item::u4(dataid), s2::Item::u4(ceid),
s2::Item::list(std::move(rows))}));
}
struct EventReportMessage {
uint32_t dataid;
uint32_t ceid;
std::vector<ReportData> reports;
};
inline std::optional<EventReportMessage> parse_s6f11(const s2::Message& m) {
if (!m.body || !m.body->is_list() || m.body->as_list().size() != 3) return std::nullopt;
const auto& outer = m.body->as_list();
auto dataid = as_u4_scalar(outer[0]);
auto ceid = as_u4_scalar(outer[1]);
if (!dataid || !ceid) return std::nullopt;
EventReportMessage out{*dataid, *ceid, {}};
if (!outer[2].is_list()) return std::nullopt;
for (const auto& row : outer[2].as_list()) {
if (!row.is_list() || row.as_list().size() != 2) return std::nullopt;
auto rptid = as_u4_scalar(row.as_list()[0]);
if (!rptid) return std::nullopt;
if (!row.as_list()[1].is_list()) return std::nullopt;
ReportData rd{*rptid, {}};
for (const auto& v : row.as_list()[1].as_list()) rd.values.push_back(v);
out.reports.push_back(std::move(rd));
}
return out;
}
inline s2::Message s6f12_event_report_ack(EventReportAck ack) {
return s2::Message(6, 12, false, s2::Item::binary({static_cast<uint8_t>(ack)}));
}
// -------------------------------------------------------------------------
// S7F3 / S7F4 Process Program Send / Ack
// S7F5 / S7F6 Process Program Request / Data
// S7F19/ S7F20 Current EPPD List Request / Data
// -------------------------------------------------------------------------
inline s2::Message s7f3_process_program_send(const std::string& ppid, const std::string& ppbody) {
return s2::Message(
7, 3, true,
s2::Item::list({s2::Item::ascii(ppid),
s2::Item::binary(std::vector<uint8_t>(ppbody.begin(), ppbody.end()))}));
}
struct ProcessProgram {
std::string ppid;
std::string ppbody;
};
inline std::optional<ProcessProgram> parse_s7f3(const s2::Message& m) {
if (!m.body || !m.body->is_list() || m.body->as_list().size() != 2) return std::nullopt;
const auto& l = m.body->as_list();
auto ppid = as_ascii(l[0]);
if (!ppid) return std::nullopt;
if (l[1].format() != s2::Format::Binary) return std::nullopt;
const auto& bytes = l[1].as_bytes();
return ProcessProgram{*ppid, std::string(bytes.begin(), bytes.end())};
}
inline s2::Message s7f4_process_program_ack(ProcessProgramAck ack) {
return s2::Message(7, 4, false, s2::Item::binary({static_cast<uint8_t>(ack)}));
}
inline s2::Message s7f5_process_program_request(const std::string& ppid) {
return s2::Message(7, 5, true, s2::Item::ascii(ppid));
}
inline std::optional<std::string> parse_s7f5(const s2::Message& m) {
if (!m.body) return std::nullopt;
return as_ascii(*m.body);
}
inline s2::Message s7f6_process_program_data(const std::string& ppid, const std::string& ppbody) {
return s2::Message(
7, 6, false,
s2::Item::list({s2::Item::ascii(ppid),
s2::Item::binary(std::vector<uint8_t>(ppbody.begin(), ppbody.end()))}));
}
inline std::optional<ProcessProgram> parse_s7f6(const s2::Message& m) {
return parse_s7f3(m); // identical body shape
}
inline s2::Message s7f19_current_eppd_request() { return s2::Message(7, 19, true); }
inline s2::Message s7f20_current_eppd_data(const std::vector<std::string>& ppids) {
s2::Item::List rows;
rows.reserve(ppids.size());
for (const auto& p : ppids) rows.push_back(s2::Item::ascii(p));
return s2::Message(7, 20, false, s2::Item::list(std::move(rows)));
}
inline std::optional<std::vector<std::string>> parse_s7f20(const s2::Message& m) {
if (!m.body || !m.body->is_list()) return std::nullopt;
std::vector<std::string> out;
for (const auto& c : m.body->as_list()) {
auto s = as_ascii(c);
if (!s) return std::nullopt;
out.push_back(*s);
}
return out;
}
} // namespace secsgem::gem
+139
View File
@@ -0,0 +1,139 @@
#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/frame.hpp"
#include "secsgem/hsms/types.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();
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
struct PendingRequest {
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,
};
std::error_code make_error(Error e);
} // namespace secsgem::hsms
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <stdexcept>
#include <vector>
#include "secsgem/hsms/header.hpp"
namespace secsgem::hsms {
class FrameError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// One HSMS message: a header plus an optional SECS-II body (control messages
// have no body). On the wire it is prefixed by a 4-byte big-endian length that
// counts the header (10) plus the body.
struct Frame {
Header header;
std::vector<uint8_t> body;
Frame() = default;
explicit Frame(Header h, std::vector<uint8_t> b = {})
: header(h), body(std::move(b)) {}
// Full wire bytes including the 4-byte length prefix.
std::vector<uint8_t> encode() const;
// Decode a message from its payload (header + body, i.e. the bytes that
// follow the length prefix). `len` must be >= 10.
static Frame decode(const uint8_t* payload, std::size_t len);
};
// HSMS message length prefix is 4 bytes; payload must be at least the 10-byte
// header.
inline constexpr std::size_t kLengthPrefixSize = 4;
inline constexpr std::size_t kHeaderSize = 10;
} // namespace secsgem::hsms
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <array>
#include <cstdint>
#include <string>
#include "secsgem/hsms/types.hpp"
namespace secsgem::hsms {
// The fixed 10-byte HSMS message header (SEMI E37). The interpretation of
// byte2/byte3 depends on `stype`: for data messages byte2 = (W<<7)|stream and
// byte3 = function; for control messages they carry status / reason codes.
struct Header {
uint16_t session_id = kControlSessionId;
uint8_t byte2 = 0;
uint8_t byte3 = 0;
uint8_t ptype = kPTypeSecsII;
SType stype = SType::Data;
uint32_t system_bytes = 0;
// Data-message field views.
bool w_bit() const { return (byte2 & 0x80) != 0; }
uint8_t stream() const { return byte2 & 0x7F; }
uint8_t function() const { return byte3; }
static Header data_message(uint16_t session_id, uint8_t stream, uint8_t function,
bool reply_expected, uint32_t system_bytes) {
Header h;
h.session_id = session_id;
h.byte2 = static_cast<uint8_t>((reply_expected ? 0x80 : 0x00) | (stream & 0x7F));
h.byte3 = function;
h.ptype = kPTypeSecsII;
h.stype = SType::Data;
h.system_bytes = system_bytes;
return h;
}
static Header control(SType stype, uint32_t system_bytes,
uint16_t session_id = kControlSessionId, uint8_t byte2 = 0,
uint8_t byte3 = 0) {
Header h;
h.session_id = session_id;
h.byte2 = byte2;
h.byte3 = byte3;
h.ptype = kPTypeSecsII;
h.stype = stype;
h.system_bytes = system_bytes;
return h;
}
std::array<uint8_t, 10> encode() const;
static Header decode(const uint8_t* data); // reads exactly 10 bytes
std::string describe() const;
bool operator==(const Header&) const = default;
};
} // namespace secsgem::hsms
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <chrono>
#include <cstdint>
namespace secsgem::hsms {
// HSMS session type (byte 5 of the message header) — SEMI E37.
enum class SType : uint8_t {
Data = 0,
SelectReq = 1,
SelectRsp = 2,
DeselectReq = 3,
DeselectRsp = 4,
LinktestReq = 5,
LinktestRsp = 6,
RejectReq = 7,
SeparateReq = 9,
};
const char* stype_name(SType s);
// Select.rsp status (header byte 3).
enum class SelectStatus : uint8_t {
Ok = 0,
AlreadyActive = 1,
NotReady = 2,
ConnectExhaust = 3,
};
// Deselect.rsp status (header byte 3).
enum class DeselectStatus : uint8_t {
Ok = 0,
NotEstablished = 1,
Busy = 2,
};
// Reject.req reason code (header byte 3).
enum class RejectReason : uint8_t {
StypeNotSupported = 1,
PtypeNotSupported = 2,
TransactionNotOpen = 3,
EntityNotSelected = 4,
};
// Presentation type 0 == SECS-II message encoding.
inline constexpr uint8_t kPTypeSecsII = 0;
// Control messages carry no device id; E37 recommends 0xFFFF.
inline constexpr uint16_t kControlSessionId = 0xFFFF;
// HSMS protocol timers (SEMI E37 defaults).
struct Timers {
std::chrono::milliseconds t3{45000}; // reply timeout
std::chrono::milliseconds t5{10000}; // connect separation timeout
std::chrono::milliseconds t6{5000}; // control transaction timeout
std::chrono::milliseconds t7{10000}; // not-selected timeout
std::chrono::milliseconds t8{5000}; // network intercharacter timeout
std::chrono::milliseconds linktest{0}; // linktest interval; 0 disables
};
} // namespace secsgem::hsms
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <cstdint>
#include <stdexcept>
#include <string>
#include <vector>
#include "secsgem/secs2/item.hpp"
namespace secsgem::secs2 {
class CodecError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// Encode a single item (with all nested children) to its SECS-II byte form.
std::vector<uint8_t> encode(const Item& item);
void encode_into(const Item& item, std::vector<uint8_t>& out);
// Decode exactly one item starting at `pos`, advancing `pos` past it.
Item decode_at(const uint8_t* data, std::size_t len, std::size_t& pos);
// Decode one item that occupies the entire buffer; throws on trailing bytes.
Item decode(const std::vector<uint8_t>& bytes);
// Human-readable SML (SECS Message Language) rendering, single line.
std::string to_sml(const Item& item);
} // namespace secsgem::secs2
+119
View File
@@ -0,0 +1,119 @@
#pragma once
#include <cstdint>
#include <stdexcept>
#include <string>
#include <variant>
#include <vector>
namespace secsgem::secs2 {
// SECS-II data item format codes (the 6-bit value, before being shifted left
// two bits to make the format byte). Values are the canonical octal codes from
// SEMI E5.
enum class Format : uint8_t {
List = 000, // 0
Binary = 010, // 8
Boolean = 011, // 9
ASCII = 020, // 16
I8 = 030, // 24
I1 = 031, // 25
I2 = 032, // 26
I4 = 034, // 28
F8 = 040, // 32
F4 = 044, // 36
U8 = 050, // 40
U1 = 051, // 41
U2 = 052, // 42
U4 = 054, // 44
};
const char* format_name(Format f);
// Number of bytes one element of the given format occupies on the wire.
// Lists are special (their length is an element count, not a byte count) and
// return 0 here.
std::size_t element_size(Format f);
// A SECS-II data item: a typed, possibly nested value. Lists hold child items;
// every other format holds a homogeneous array of scalars (a single scalar is
// just an array of length one). The active variant alternative is kept in sync
// with `format_`; several formats (Binary, Boolean, U1) share the same C++
// storage type and are disambiguated by `format_`.
class Item {
public:
using List = std::vector<Item>;
using Storage = std::variant<
List, // List
std::string, // ASCII
std::vector<uint8_t>, // Binary, Boolean, U1
std::vector<int8_t>, // I1
std::vector<int16_t>, // I2
std::vector<int32_t>, // I4
std::vector<int64_t>, // I8
std::vector<uint16_t>, // U2
std::vector<uint32_t>, // U4
std::vector<uint64_t>, // U8
std::vector<float>, // F4
std::vector<double>>; // F8
Item() : format_(Format::List), data_(List{}) {}
Format format() const { return format_; }
bool is_list() const { return format_ == Format::List; }
// Number of elements: child count for lists, character count for ASCII,
// array length for numeric/binary formats.
std::size_t size() const;
// --- Factory functions -------------------------------------------------
static Item list(List items) { return Item(Format::List, std::move(items)); }
static Item ascii(std::string s) { return Item(Format::ASCII, std::move(s)); }
static Item binary(std::vector<uint8_t> b) { return Item(Format::Binary, std::move(b)); }
static Item boolean(std::vector<uint8_t> b) { return Item(Format::Boolean, std::move(b)); }
static Item boolean(bool b) { return Item(Format::Boolean, std::vector<uint8_t>{static_cast<uint8_t>(b ? 1 : 0)}); }
static Item u1(std::vector<uint8_t> v) { return Item(Format::U1, std::move(v)); }
static Item u2(std::vector<uint16_t> v) { return Item(Format::U2, std::move(v)); }
static Item u4(std::vector<uint32_t> v) { return Item(Format::U4, std::move(v)); }
static Item u8(std::vector<uint64_t> v) { return Item(Format::U8, std::move(v)); }
static Item i1(std::vector<int8_t> v) { return Item(Format::I1, std::move(v)); }
static Item i2(std::vector<int16_t> v) { return Item(Format::I2, std::move(v)); }
static Item i4(std::vector<int32_t> v) { return Item(Format::I4, std::move(v)); }
static Item i8(std::vector<int64_t> v) { return Item(Format::I8, std::move(v)); }
static Item f4(std::vector<float> v) { return Item(Format::F4, std::move(v)); }
static Item f8(std::vector<double> v) { return Item(Format::F8, std::move(v)); }
// Scalar convenience overloads.
static Item u1(uint8_t v) { return u1(std::vector<uint8_t>{v}); }
static Item u2(uint16_t v) { return u2(std::vector<uint16_t>{v}); }
static Item u4(uint32_t v) { return u4(std::vector<uint32_t>{v}); }
static Item u8(uint64_t v) { return u8(std::vector<uint64_t>{v}); }
static Item i1(int8_t v) { return i1(std::vector<int8_t>{v}); }
static Item i2(int16_t v) { return i2(std::vector<int16_t>{v}); }
static Item i4(int32_t v) { return i4(std::vector<int32_t>{v}); }
static Item i8(int64_t v) { return i8(std::vector<int64_t>{v}); }
static Item f4(float v) { return f4(std::vector<float>{v}); }
static Item f8(double v) { return f8(std::vector<double>{v}); }
// Construct directly from a format and matching storage (used by the decoder).
static Item raw(Format f, Storage s) { return Item(f, std::move(s)); }
// --- Typed accessors (throw std::bad_variant_access on mismatch) --------
const List& as_list() const { return std::get<List>(data_); }
List& as_list() { return std::get<List>(data_); }
const std::string& as_ascii() const { return std::get<std::string>(data_); }
const std::vector<uint8_t>& as_bytes() const { return std::get<std::vector<uint8_t>>(data_); }
const Storage& storage() const { return data_; }
bool operator==(const Item&) const = default;
private:
Item(Format f, Storage s) : format_(f), data_(std::move(s)) {}
Format format_;
Storage data_;
};
} // namespace secsgem::secs2
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "secsgem/secs2/item.hpp"
namespace secsgem::secs2 {
// A logical SECS-II message: stream/function, the W-bit (reply expected), and
// an optional root data item as the body. The transport (HSMS) header carries
// stream/function/W on the wire; the body bytes are the encoded root item.
struct Message {
uint8_t stream = 0;
uint8_t function = 0;
bool reply_expected = false; // W-bit
std::optional<Item> body;
Message() = default;
Message(uint8_t s, uint8_t f, bool w, std::optional<Item> b = std::nullopt)
: stream(s), function(f), reply_expected(w), body(std::move(b)) {}
// Encode the body item to bytes (empty if there is no body).
std::vector<uint8_t> encode_body() const;
// Build a Message from stream/function/W and raw body bytes (empty -> no body).
static Message from_body(uint8_t stream, uint8_t function, bool reply_expected,
const std::vector<uint8_t>& body_bytes);
// e.g. S1F2 W
// <L [2] <A "MDLN"> <A "1.0"> >
std::string sml() const;
};
} // namespace secsgem::secs2