diff --git a/CMakeLists.txt b/CMakeLists.txt index 38477f4..b6526bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -128,6 +128,7 @@ add_executable(secsgem_tests tests/test_e84.cpp tests/test_gem300_scenario.cpp tests/test_wire_ceid_emission.cpp + tests/test_live_gem300.cpp ) target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest) target_compile_definitions(secsgem_tests PRIVATE diff --git a/tests/test_live_gem300.cpp b/tests/test_live_gem300.cpp new file mode 100644 index 0000000..db42a6d --- /dev/null +++ b/tests/test_live_gem300.cpp @@ -0,0 +1,282 @@ +// Live-HSMS GEM 300 scenario. Mirrors the in-memory test in +// test_gem300_scenario.cpp but drives every step through real HSMS +// frames on a loopback socket pair, validating both the dispatch +// path (host → equipment) and the CEID emission path +// (equipment → host) for the full lifecycle. +// +// Equipment-side emulator wires: +// S1F13 → S1F14 establish comm +// S3F17 → S3F18 carrier action (proceed) +// S16F11 → S16F12 PJ create +// S14F9 → S14F10 CJ create +// S16F27 → S16F28 CJ command +// and auto-emits S6F11 on CJ Executing transition + S16F9 on each +// PJ state change. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "secsgem/gem/data_model.hpp" +#include "secsgem/gem/messages.hpp" +#include "secsgem/hsms/connection.hpp" +#include "secsgem/hsms/header.hpp" +#include "secsgem/secs2/message.hpp" + +using namespace secsgem; +using namespace std::chrono_literals; + +namespace { + +struct SocketPair { + asio::io_context io; + asio::ip::tcp::socket a{io}; + asio::ip::tcp::socket b{io}; + + SocketPair() { + asio::ip::tcp::acceptor acc(io, asio::ip::tcp::endpoint( + asio::ip::address_v4::loopback(), 0)); + const auto port = acc.local_endpoint().port(); + bool da = false, db = false; + std::error_code ea, eb; + acc.async_accept(a, [&](std::error_code ec) { ea = ec; da = true; }); + b.async_connect({asio::ip::address_v4::loopback(), port}, + [&](std::error_code ec) { eb = ec; db = true; }); + while (!(da && db)) { + if (io.stopped()) io.restart(); + if (io.poll() == 0) std::this_thread::sleep_for(1ms); + } + REQUIRE_FALSE(ea); + REQUIRE_FALSE(eb); + } +}; + +template +void pump_until(asio::io_context& io, Pred pred, + std::chrono::milliseconds budget = 3s) { + const auto deadline = std::chrono::steady_clock::now() + budget; + while (!pred()) { + if (std::chrono::steady_clock::now() > deadline) FAIL("pump_until budget exceeded"); + if (io.stopped()) io.restart(); + if (io.poll() == 0) std::this_thread::sleep_for(1ms); + } +} + +hsms::Timers permissive_timers() { + hsms::Timers t; + t.t3 = 5s; t.t6 = 5s; t.t7 = 5s; t.t8 = 5s; t.linktest = 0ms; + return t; +} + +static constexpr uint32_t kCeidControlJobExecuting = 400; + +// EquipmentEmulator: model + connection + per-stream handlers. +struct EquipmentEmulator { + gem::EquipmentDataModel m; + std::shared_ptr conn; + uint32_t dataid = 1; + + explicit EquipmentEmulator(asio::ip::tcp::socket socket) { + conn = std::make_shared( + std::move(socket), hsms::Connection::Mode::Passive, /*device_id=*/0, + permissive_timers()); + + m.recipes.add("RECIPE-A", "step1"); + m.svids.add({1, "STATUS", "", secs2::Item::ascii("READY")}); + m.events.register_event({kCeidControlJobExecuting, "ControlJobExecuting"}); + + // Wire CJ → emit S6F11 when entering Executing. + auto* conn_raw = conn.get(); + m.control_jobs.set_state_change_handler( + [this, conn_raw](const std::string&, gem::ControlJobState, + gem::ControlJobState to, gem::ControlJobEvent) { + if (to != gem::ControlJobState::Executing) return; + if (!m.is_event_enabled(kCeidControlJobExecuting)) return; + conn_raw->send_data(gem::s6f11_event_report( + dataid++, kCeidControlJobExecuting, + m.compose_reports_for(kCeidControlJobExecuting))); + }); + + // Wire PJ → emit S16F9 on every (non-Created) transition. + m.process_jobs.set_state_change_handler( + [conn_raw](const std::string& prjobid, gem::ProcessJobState, + gem::ProcessJobState to, gem::ProcessJobEvent trigger) { + if (trigger == gem::ProcessJobEvent::Created) return; + conn_raw->send_data(gem::s16f9_pr_job_alert(prjobid, to)); + }); + + conn->set_message_handler( + [this](const secs2::Message& msg) -> std::optional { + return dispatch(msg); + }); + } + + std::optional dispatch(const secs2::Message& msg) { + if (msg.stream == 1 && msg.function == 13) { + return gem::s1f14_establish_comms_ack(gem::CommAck::Accept, gem::McAck{"E", "1.0"}); + } + if (msg.stream == 3 && msg.function == 17) { + auto req = gem::parse_s3f17(msg); + if (!req) return gem::s3f18_carrier_action_ack(gem::CarrierActionAck::CarrierActionInvalid); + if (!m.carriers.has(req->carrierid)) m.carriers.create(req->carrierid, 1, 4); + if (req->carrieraction == "ProceedWithCarrier") { + m.carriers.fire_id_event(req->carrierid, gem::CarrierIDEvent::ProceedWithCarrier); + } + return gem::s3f18_carrier_action_ack(gem::CarrierActionAck::Accept); + } + if (msg.stream == 16 && msg.function == 11) { + auto req = gem::parse_s16f11(msg); + if (!req) return gem::s16f12_pr_job_create_ack(gem::HostCmdAck::InvalidCommand); + auto rc = m.process_jobs.create( + req->prjobid, req->rcpspec.ppid, req->mtrloutspec, + [&](const std::string& ppid) { return m.recipes.get(ppid).has_value(); }); + return gem::s16f12_pr_job_create_ack( + rc == gem::ProcessJobStore::CreateResult::Created + ? gem::HostCmdAck::Accept + : gem::HostCmdAck::CannotDoNow); + } + if (msg.stream == 14 && msg.function == 9) { + auto req = gem::parse_s14f9(msg); + if (!req) { + return gem::s14f10_create_control_job_ack("", + gem::ObjectAck::Denied_InvalidAttribute); + } + auto rc = m.control_jobs.create(req->ctljobid, req->prjobids, + [&](const std::string& id) { + return m.process_jobs.has(id); + }); + return gem::s14f10_create_control_job_ack( + req->ctljobid, + rc == gem::ControlJobStore::CreateResult::Created + ? gem::ObjectAck::Success + : gem::ObjectAck::Denied_AlreadyExists); + } + if (msg.stream == 16 && msg.function == 27) { + auto req = gem::parse_s16f27(msg); + if (!req) return gem::s16f28_cj_command_ack(gem::HostCmdAck::InvalidCommand); + auto event = gem::ctl_cmd_to_event(req->ctljobcmd); + if (!event) return gem::s16f28_cj_command_ack(gem::HostCmdAck::InvalidCommand); + // Walk Queued → Selected → WaitingForStart → Executing on CJSTART. + if (*event == gem::ControlJobEvent::Start) { + m.control_jobs.fire_internal(req->ctljobid, gem::ControlJobEvent::Select); + m.control_jobs.fire_internal(req->ctljobid, gem::ControlJobEvent::SetupComplete); + } + auto ack = m.control_jobs.on_host_command(req->ctljobid, *event); + return gem::s16f28_cj_command_ack(ack); + } + return std::nullopt; + } +}; + +struct HostEmulator { + std::shared_ptr conn; + // Captured equipment → host primaries. + std::vector primaries; + + explicit HostEmulator(asio::ip::tcp::socket socket) { + conn = std::make_shared( + std::move(socket), hsms::Connection::Mode::Active, 0, permissive_timers()); + conn->set_message_handler( + [this](const secs2::Message& m) -> std::optional { + primaries.push_back(m); + return std::nullopt; + }); + } + + bool saw(uint8_t stream, uint8_t function) const { + for (const auto& m : primaries) + if (m.stream == stream && m.function == function) return true; + return false; + } +}; + +} // namespace + +TEST_CASE("Live HSMS GEM300 lifecycle: comm → carrier → PJ → CJ → execute") { + SocketPair sp; + EquipmentEmulator eq(std::move(sp.a)); + HostEmulator host(std::move(sp.b)); + + // Subscribe to ControlJobExecuting so the equipment will emit S6F11. + eq.m.events.enable_events(true, {kCeidControlJobExecuting}); + + bool eq_sel = false, host_sel = false; + eq.conn->set_selected_handler([&] { eq_sel = true; }); + host.conn->set_selected_handler([&] { host_sel = true; }); + eq.conn->start(); + host.conn->start(); + pump_until(sp.io, [&] { return eq_sel && host_sel; }); + + auto send_request = [&](secs2::Message req, uint8_t expected_function) { + std::optional reply; + host.conn->send_request(std::move(req), + [&](std::error_code ec, const secs2::Message& m) { + REQUIRE_FALSE(ec); + reply = m; + }); + pump_until(sp.io, [&] { return reply.has_value(); }); + CHECK(reply->function == expected_function); + return *reply; + }; + + // 1. Establish comms. + auto comm_ack = send_request( + gem::s1f13_establish_comms("HOST", "1.0"), /*expected=*/14); + CHECK(comm_ack.stream == 1); + CHECK(comm_ack.function == 14); + + // 2. Carrier action: ProceedWithCarrier "CAR-1". + auto car_ack = send_request( + gem::s3f17_carrier_action(1, "ProceedWithCarrier", "CAR-1", {}), + /*expected=*/18); + CHECK(car_ack.stream == 3); + CHECK(car_ack.function == 18); + CHECK(eq.m.carriers.has("CAR-1")); + CHECK(eq.m.carriers.get("CAR-1")->fsm->id_status() == + gem::CarrierIDStatus::Confirmed); + + // 3. PJ create. + gem::PRJobCreateRequest pj_req; + pj_req.prjobid = "PJ-1"; + pj_req.mf = gem::MaterialFlag::Substrate; + pj_req.prrecipemethod = gem::ProcessRecipeMethod::RecipeOnly; + pj_req.rcpspec.ppid = "RECIPE-A"; + pj_req.mtrloutspec = {"W-1"}; + auto pj_ack = send_request(gem::s16f11_pr_job_create(pj_req), 12); + CHECK(eq.m.process_jobs.has("PJ-1")); + + // 4. CJ create. + auto cj_ack = send_request(gem::s14f9_create_control_job("CJ-1", {"PJ-1"}), 10); + auto parsed_cj = gem::parse_s14f10(cj_ack); + REQUIRE(parsed_cj.has_value()); + CHECK(parsed_cj->ack == gem::ObjectAck::Success); + CHECK(eq.m.control_jobs.has("CJ-1")); + + // 5. CJ Start. + auto cmd_ack = send_request(gem::s16f27_cj_command("CJ-1", "CJSTART"), 28); + CHECK(eq.m.control_jobs.get("CJ-1")->fsm->state() == + gem::ControlJobState::Executing); + + // 6. Equipment auto-emitted S6F11(ControlJobExecuting) — wait for it. + pump_until(sp.io, [&] { return host.saw(6, 11); }); + // Decode and assert. + for (const auto& m : host.primaries) { + if (m.stream == 6 && m.function == 11) { + auto parsed = gem::parse_s6f11(m); + REQUIRE(parsed.has_value()); + CHECK(parsed->ceid == kCeidControlJobExecuting); + } + } + + // 7. CJ Complete (internal) — observed on equipment side, no wire frame yet. + eq.m.control_jobs.fire_internal("CJ-1", gem::ControlJobEvent::AllJobsComplete); + CHECK(eq.m.control_jobs.get("CJ-1")->fsm->state() == + gem::ControlJobState::Completed); +}